diff --git a/.gitignore b/.gitignore index 3716042..e356d0c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ Package.resolved .DS_Store *.swp *~ +.claude.local.md diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index c7abfe2..eb605a3 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -264,7 +264,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -280,7 +280,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -301,7 +301,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -317,7 +317,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index f1f4a59..373a139 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> = dialog.lastMessageTimestamp { + dialog.lastMessage = decryptedText + dialog.lastMessageTimestamp = incomingTimestamp + dialog.lastMessageFromMe = fromMe + dialog.lastMessageDelivered = fromMe ? .waiting : .delivered + } if fromMe { dialog.iHaveSent = true - } else { + } else if isNewMessage && !isDialogActive { + // Only increment unread for genuinely new incoming messages + // when the user is NOT currently viewing this dialog. dialog.unreadCount += 1 } @@ -188,9 +208,9 @@ final class DialogRepository { if !title.isEmpty { dialog.opponentTitle = title } if !username.isEmpty { dialog.opponentUsername = username } if verified > 0 { dialog.verified = max(dialog.verified, verified) } - // online: 0 = offline, 1 = online, -1 = not provided (don't update) + // online: server sends inverted values (0 = online, 1 = offline), -1 = not provided if online >= 0 { - dialog.isOnline = online > 0 + dialog.isOnline = online == 0 if !dialog.isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) } } dialogs[publicKey] = dialog diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketOnlineState.swift b/Rosetta/Core/Network/Protocol/Packets/PacketOnlineState.swift index 0fbf0b6..b0c162f 100644 --- a/Rosetta/Core/Network/Protocol/Packets/PacketOnlineState.swift +++ b/Rosetta/Core/Network/Protocol/Packets/PacketOnlineState.swift @@ -21,8 +21,9 @@ struct PacketOnlineState: Packet { var list: [OnlineStateEntry] = [] for _ in 0.. Void)? var onDisconnected: ((Error?) -> Void)? @@ -23,6 +28,9 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD super.init() let config = URLSessionConfiguration.default config.waitsForConnectivity = true + // Match Android OkHttp readTimeout(0) — disable client-side idle timeout + config.timeoutIntervalForRequest = 604800 // 1 week + config.timeoutIntervalForResource = 604800 session = URLSession(configuration: config, delegate: self, delegateQueue: nil) } @@ -88,6 +96,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD hasNotifiedConnected = true isConnected = true disconnectHandledForCurrentSocket = false + reconnectAttempts = 0 // Match Android: reset on successful connection reconnectTask?.cancel() reconnectTask = nil onConnected?() @@ -139,10 +148,17 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD guard !isManuallyClosed else { return } + // Match Android: MAX_RECONNECT_ATTEMPTS = 5 + guard reconnectAttempts < Self.maxReconnectAttempts else { + Self.logger.error("Max reconnect attempts reached (\(Self.maxReconnectAttempts))") + return + } + guard reconnectTask == nil else { return } + reconnectAttempts += 1 reconnectTask = Task { [weak self] in - Self.logger.info("Reconnecting in 5 seconds...") - try? await Task.sleep(nanoseconds: 5_000_000_000) + Self.logger.info("Reconnecting in 10 seconds (attempt \(self?.reconnectAttempts ?? 0)/\(Self.maxReconnectAttempts))...") + try? await Task.sleep(nanoseconds: Self.reconnectInterval) guard let self, !isManuallyClosed, !Task.isCancelled else { return } self.reconnectTask = nil self.connect() diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index d85da30..f1f18ad 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -410,6 +410,7 @@ final class SessionManager { let fromMe = packet.fromPublicKey == myKey let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId) + let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) let decryptedText = Self.decryptIncomingMessage( packet: packet, @@ -425,7 +426,11 @@ final class SessionManager { } DialogRepository.shared.updateFromMessage( - packet, myPublicKey: myKey, decryptedText: text + packet, + myPublicKey: myKey, + decryptedText: text, + isNewMessage: !wasKnownBefore, + isDialogActive: dialogIsActive ) MessageRepository.shared.upsertFromMessagePacket( packet, diff --git a/Rosetta/DesignSystem/Components/AvatarView.swift b/Rosetta/DesignSystem/Components/AvatarView.swift index 26dcdc9..11ed8bc 100644 --- a/Rosetta/DesignSystem/Components/AvatarView.swift +++ b/Rosetta/DesignSystem/Components/AvatarView.swift @@ -38,16 +38,16 @@ struct AvatarView: View { } } .frame(width: size, height: size) - .overlay(alignment: .bottomLeading) { + .overlay(alignment: .bottomTrailing) { if isOnline { Circle() - .fill(Color(hex: 0x4CD964)) - .frame(width: badgeSize, height: badgeSize) + .fill(RosettaColors.figmaBlue) + .frame(width: size * 0.19, height: size * 0.19) .overlay { Circle() - .stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.05) + .stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.04) } - .offset(x: -1, y: 1) + .offset(x: 0, y: -size * 0.06) } } .accessibilityLabel(isSavedMessages ? "Saved Messages" : initials) diff --git a/Rosetta/DesignSystem/Components/ButtonStyles.swift b/Rosetta/DesignSystem/Components/ButtonStyles.swift index d7e28ef..95ee1c1 100644 --- a/Rosetta/DesignSystem/Components/ButtonStyles.swift +++ b/Rosetta/DesignSystem/Components/ButtonStyles.swift @@ -19,20 +19,9 @@ struct GlassBackButton: View { .clipShape(Circle()) } - @ViewBuilder private var glassCircle: some View { - if #available(iOS 26, *) { - Circle() - .fill(Color.white.opacity(0.08)) - .glassEffect(.regular, in: .circle) - } else { - Circle() - .fill(Color.white.opacity(0.08)) - .overlay { - Circle() - .stroke(Color.white.opacity(0.12), lineWidth: 0.5) - } - } + Circle() + .fill(Color.white.opacity(0.15)) } } @@ -46,19 +35,9 @@ struct RosettaPrimaryButtonStyle: ButtonStyle { } func makeBody(configuration: Configuration) -> some View { - Group { - if #available(iOS 26, *) { - configuration.label - .background { - Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.7 : 1.0)) - } - .glassEffect(.regular, in: Capsule()) - } else { - configuration.label - .background { glassBackground(isPressed: configuration.isPressed) } - .clipShape(Capsule()) - } - } + configuration.label + .background { glassBackground(isPressed: configuration.isPressed) } + .clipShape(Capsule()) .scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0) .animation(.easeOut(duration: 0.15), value: configuration.isPressed) .allowsHitTesting(isEnabled) @@ -67,20 +46,6 @@ struct RosettaPrimaryButtonStyle: ButtonStyle { private func glassBackground(isPressed: Bool) -> some View { Capsule() .fill(fillColor.opacity(isPressed ? 0.7 : 1.0)) - .overlay { - Capsule() - .fill( - LinearGradient( - colors: [ - Color.white.opacity(isEnabled ? 0.18 : 0.05), - Color.clear, - Color.black.opacity(0.08), - ], - startPoint: .top, - endPoint: .bottom - ) - ) - } } } diff --git a/Rosetta/DesignSystem/Components/GlassCard.swift b/Rosetta/DesignSystem/Components/GlassCard.swift index 3265eb9..f01283f 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -16,25 +16,13 @@ struct GlassCard: View { } var body: some View { - if #available(iOS 26, *) { - content() - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) - } else { - content() - .background { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(RosettaColors.adaptive( - light: Color.black.opacity(fillOpacity), - dark: Color.white.opacity(fillOpacity) - )) - .overlay { - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(RosettaColors.adaptive( - light: Color.black.opacity(0.06), - dark: Color.white.opacity(0.08) - ), lineWidth: 0.5) - } - } - } + content() + .background { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(RosettaColors.adaptive( + light: Color.black.opacity(fillOpacity), + dark: Color.white.opacity(fillOpacity) + )) + } } } diff --git a/Rosetta/DesignSystem/Components/GlassModifier.swift b/Rosetta/DesignSystem/Components/GlassModifier.swift index 0a16bf4..b769c7e 100644 --- a/Rosetta/DesignSystem/Components/GlassModifier.swift +++ b/Rosetta/DesignSystem/Components/GlassModifier.swift @@ -1,81 +1,45 @@ import SwiftUI -// MARK: - Glass Modifier (5-layer glass that works on black) +// MARK: - Glass Modifier // -// Layer stack: -// 1. .ultraThinMaterial — system blur -// 2. black.opacity(0.22) — dark tint (depth on dark mode) -// 3. white→clear gradient — highlight / light refraction, blendMode(.screen) -// 4. double stroke — outer weak + inner stronger = glass edge -// 5. shadow — depth +// Solid adaptive background — no glass or material effects struct GlassModifier: ViewModifier { let cornerRadius: CGFloat - func body(content: Content) -> some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + private var fillColor: Color { + RosettaColors.adaptive( + light: Color(hex: 0xF2F2F7), + dark: Color(hex: 0x1C1C1E) + ) + } - if #available(iOS 26, *) { - content - .background { - shape.fill(.clear) - .glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) - } - } else { - content - .background { - ZStack { - shape.fill(.ultraThinMaterial) - shape.fill(Color.black.opacity(0.22)) - shape.fill( - LinearGradient( - colors: [Color.white.opacity(0.14), .clear], - startPoint: .top, - endPoint: .bottom - ) - ).blendMode(.screen) - shape.stroke(Color.white.opacity(0.10), lineWidth: 1) - shape.stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) - } - .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) - } - } + func body(content: Content) -> some View { + content + .background { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(fillColor) + } } } // MARK: - View Extension extension View { - /// 5-layer frosted glass background. + /// Solid background with rounded corners. func glass(cornerRadius: CGFloat = 24) -> some View { modifier(GlassModifier(cornerRadius: cornerRadius)) } - /// Glass capsule — convenience for pill-shaped elements. - @ViewBuilder + /// Solid capsule background — convenience for pill-shaped elements. func glassCapsule() -> some View { - if #available(iOS 26, *) { - background { - Capsule().fill(.clear) - .glassEffect(.regular, in: .capsule) - } - } else { - background { - ZStack { - Capsule().fill(.ultraThinMaterial) - Capsule().fill(Color.black.opacity(0.22)) - Capsule().fill( - LinearGradient( - colors: [Color.white.opacity(0.14), .clear], - startPoint: .top, - endPoint: .bottom - ) - ).blendMode(.screen) - Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1) - Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) - } - .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) - } + background { + Capsule().fill( + RosettaColors.adaptive( + light: Color(hex: 0xF2F2F7), + dark: Color(hex: 0x1C1C1E) + ) + ) } } } diff --git a/Rosetta/DesignSystem/Components/GlassModifiers.swift b/Rosetta/DesignSystem/Components/GlassModifiers.swift index 0db332e..c953437 100644 --- a/Rosetta/DesignSystem/Components/GlassModifiers.swift +++ b/Rosetta/DesignSystem/Components/GlassModifiers.swift @@ -5,12 +5,8 @@ import SwiftUI /// Applies glassmorphism effect to the navigation bar on iOS 26+, falling back to ultra-thin material. struct GlassNavBarModifier: ViewModifier { func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - } else { - content - .toolbarBackground(.ultraThinMaterial, for: .navigationBar) - } + content + .toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar) } } @@ -25,12 +21,7 @@ extension View { /// Applies glassmorphism capsule effect on iOS 26+. struct GlassSearchBarModifier: ViewModifier { func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - .glassEffect(.regular, in: .capsule) - } else { - content - } + content } } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index 1a3ebf4..cff59fc 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -52,479 +52,297 @@ struct TabBarSwipeState { let fractionalIndex: CGFloat } +// MARK: - Tab Bar Colors + +private enum TabBarColors { + static let pillBackground = Color(hex: 0x2C2C2E) + static let selectionBackground = Color(hex: 0x3A3A3C) + static let selectedTint = Color(hex: 0x008BFF) + static let unselectedTint = Color.white + static let pillBorder = Color.white.opacity(0.08) +} + +// MARK: - Preference Keys + +private struct TabWidthPreferenceKey: PreferenceKey { + static var defaultValue: [Int: CGFloat] = [:] + static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) { + value.merge(nextValue()) { $1 } + } +} + +private struct TabOriginPreferenceKey: PreferenceKey { + static var defaultValue: [Int: CGFloat] = [:] + static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) { + value.merge(nextValue()) { $1 } + } +} + // MARK: - RosettaTabBar struct RosettaTabBar: View { let selectedTab: RosettaTab var onTabSelected: ((RosettaTab) -> Void)? var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)? - var badges: [TabBadge] = [] - @State private var tabFrames: [RosettaTab: CGRect] = [:] - @State private var interactionState: TabPressInteraction? + private let allTabs = RosettaTab.interactionOrder + private let tabCount = RosettaTab.interactionOrder.count - private static let tabBarSpace = "RosettaTabBarSpace" - private let lensLiftOffset: CGFloat = 12 + // Drag state + @State private var isDragging = false + @State private var dragFractional: CGFloat = 0 + @State private var dragStartIndex: CGFloat = 0 - var body: some View { - interactiveTabBarContent - .padding(.horizontal, 25) - .padding(.top, 4) + // Measured tab geometry + @State private var tabWidths: [Int: CGFloat] = [:] + @State private var tabOrigins: [Int: CGFloat] = [:] + + /// Cached badge text to avoid reading DialogRepository inside body + /// (which creates @Observable tracking and causes re-render storms during drag). + @State private var cachedBadgeText: String? + + private var effectiveFractional: CGFloat { + isDragging ? dragFractional : CGFloat(selectedTab.interactionIndex) } - private var interactiveTabBarContent: some View { - tabBarContent - .coordinateSpace(name: Self.tabBarSpace) - .onPreferenceChange(TabFramePreferenceKey.self) { frames in - tabFrames = frames + var body: some View { + // Single pill with all tabs — same structure as iOS 26 system TabView + HStack(spacing: 0) { + ForEach(Array(allTabs.enumerated()), id: \.element) { index, tab in + tabContent(tab: tab, index: index) + .background( + GeometryReader { geo in + Color.clear + .preference( + key: TabWidthPreferenceKey.self, + value: [index: geo.size.width] + ) + .preference( + key: TabOriginPreferenceKey.self, + value: [index: geo.frame(in: .named("tabBar")).minX] + ) + } + ) } - .contentShape(Rectangle()) - .gesture(tabSelectionGesture) - .overlay(alignment: .topLeading) { - liftedLensOverlay + } + .padding(4) + .coordinateSpace(name: "tabBar") + .onPreferenceChange(TabWidthPreferenceKey.self) { tabWidths = $0 } + .onPreferenceChange(TabOriginPreferenceKey.self) { tabOrigins = $0 } + .background(alignment: .leading) { + selectionIndicator + } + .background { + if #available(iOS 26.0, *) { + // iOS 26+ — liquid glass material for the capsule pill + Capsule() + .fill(.ultraThinMaterial) + } else { + // iOS < 26 — solid dark capsule + Capsule() + .fill(TabBarColors.pillBackground) + .overlay( + Capsule() + .strokeBorder(TabBarColors.pillBorder, lineWidth: 0.5) + ) } - .onDisappear { - interactionState = nil + } + .contentShape(Capsule()) + .gesture(dragGesture) + .modifier(TabBarShadowModifier()) + .padding(.horizontal, 25) + .padding(.top, 16) + .padding(.bottom, 12) + .onAppear { Task { @MainActor in refreshBadges() } } + .onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } } + } + + /// Reads DialogRepository outside the body's observation scope. + private func refreshBadges() { + let repo = DialogRepository.shared + let unread = repo.sortedDialogs + .filter { !$0.isMuted } + .reduce(0) { $0 + $1.unreadCount } + if unread <= 0 { + cachedBadgeText = nil + } else { + cachedBadgeText = unread > 999 ? "\(unread / 1000)K" : "\(unread)" + } + } + + // MARK: - Selection Indicator + + @ViewBuilder + private var selectionIndicator: some View { + let frac = effectiveFractional + let nearestIdx = Int(frac.rounded()).clamped(to: 0...(tabCount - 1)) + let width = tabWidths[nearestIdx] ?? 80 + let xOffset = interpolatedOrigin(for: frac) + + Group { + if #available(iOS 26.0, *) { + Capsule().fill(.thinMaterial) + .frame(width: width) + .offset(x: xOffset) + } else { + Capsule().fill(TabBarColors.selectionBackground) + .frame(width: width - 4) + .padding(.vertical, 4) + .offset(x: xOffset) + } + } + .animation( + isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82), + value: frac + ) + } + + private func interpolatedOrigin(for fractional: CGFloat) -> CGFloat { + let lower = Int(fractional).clamped(to: 0...(tabCount - 1)) + let upper = (lower + 1).clamped(to: 0...(tabCount - 1)) + let t = fractional - CGFloat(lower) + let lowerX = tabOrigins[lower] ?? 0 + let upperX = tabOrigins[upper] ?? lowerX + return lowerX + (upperX - lowerX) * t + } + + // MARK: - Drag Gesture + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 8) + .onChanged { value in + if !isDragging { + isDragging = true + dragStartIndex = CGFloat(selectedTab.interactionIndex) + } + + let avgTabWidth = totalTabWidth / CGFloat(tabCount) + guard avgTabWidth > 0 else { return } + let delta = value.translation.width / avgTabWidth + let newFrac = (dragStartIndex - delta) + .clamped(to: 0...CGFloat(tabCount - 1)) + + dragFractional = newFrac + + let nearestIdx = Int(newFrac.rounded()).clamped(to: 0...(tabCount - 1)) + onSwipeStateChanged?(TabBarSwipeState( + fromTab: selectedTab, + hoveredTab: allTabs[nearestIdx], + fractionalIndex: newFrac + )) + } + .onEnded { value in + let avgTabWidth = totalTabWidth / CGFloat(tabCount) + let velocity = avgTabWidth > 0 ? value.predictedEndTranslation.width / avgTabWidth : 0 + let projected = dragFractional - velocity * 0.15 + let snappedIdx = Int(projected.rounded()).clamped(to: 0...(tabCount - 1)) + let targetTab = allTabs[snappedIdx] + + isDragging = false + dragFractional = CGFloat(snappedIdx) + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onTabSelected?(targetTab) onSwipeStateChanged?(nil) } } - private var tabBarContent: some View { - HStack(spacing: 8) { - mainTabsPill - searchPill - } + private var totalTabWidth: CGFloat { + tabWidths.values.reduce(0, +) } - private var visualSelectedTab: RosettaTab { - if let interactionState, interactionState.isLifted { - return interactionState.hoveredTab - } - return selectedTab - } + // MARK: - Tab Content - private var tabSelectionGesture: some Gesture { - DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace)) - .onChanged(handleGestureChanged) - .onEnded(handleGestureEnded) - } + private func tabContent(tab: RosettaTab, index: Int) -> some View { + let frac = effectiveFractional + let distance = abs(frac - CGFloat(index)) + let blend = (1 - distance).clamped(to: 0...1) + let tint = tintColor(blend: blend) + let isEffectivelySelected = blend > 0.5 + let badge: String? = (tab == .chats) ? cachedBadgeText : nil - private func handleGestureChanged(_ value: DragGesture.Value) { - guard !tabFrames.isEmpty else { - return - } - - if interactionState == nil { - guard let startTab = tabAtStart(location: value.startLocation), - let startFrame = tabFrames[startTab] - else { - return - } - - let state = TabPressInteraction( - id: UUID(), - startTab: startTab, - startCenterX: startFrame.midX, - currentCenterX: startFrame.midX, - hoveredTab: startTab, - isLifted: true - ) + return Button { + guard !isDragging else { return } UIImpactFeedbackGenerator(style: .light).impactOccurred() - interactionState = state - publishSwipeState(for: state) - return - } - - guard var state = interactionState else { - return - } - - state.currentCenterX = clampedCenterX(state.startCenterX + value.translation.width) - - if let nearest = nearestTab(toX: state.currentCenterX), nearest != state.hoveredTab { - state.hoveredTab = nearest - if state.isLifted { - UIImpactFeedbackGenerator(style: .soft).impactOccurred() - } - } - - interactionState = state - publishSwipeState(for: state) - } - - private func handleGestureEnded(_ value: DragGesture.Value) { - guard let state = interactionState else { - return - } - - let targetTab = nearestTab(toX: value.location.x) ?? state.hoveredTab - - withAnimation(.spring(response: 0.34, dampingFraction: 0.72)) { - interactionState = nil - } - - onSwipeStateChanged?(nil) - onTabSelected?(targetTab) - } - - private func publishSwipeState(for state: TabPressInteraction) { - guard state.isLifted, - let fractionalIndex = fractionalIndex(for: state.currentCenterX) - else { - onSwipeStateChanged?(nil) - return - } - - onSwipeStateChanged?( - TabBarSwipeState( - fromTab: state.startTab, - hoveredTab: state.hoveredTab, - fractionalIndex: fractionalIndex - ) - ) - } - - private func tabAtStart(location: CGPoint) -> RosettaTab? { - guard let nearest = nearestTab(toX: location.x), - let frame = tabFrames[nearest] - else { - return nil - } - - return frame.insetBy(dx: -18, dy: -18).contains(location) ? nearest : nil - } - - private func nearestTab(toX x: CGFloat) -> RosettaTab? { - tabFrames.min { lhs, rhs in - abs(lhs.value.midX - x) < abs(rhs.value.midX - x) - }?.key - } - - private func clampedCenterX(_ value: CGFloat) -> CGFloat { - let centers = tabFrames.values.map(\.midX) - guard let minX = centers.min(), let maxX = centers.max() else { - return value - } - return min(max(value, minX), maxX) - } - - private func fractionalIndex(for centerX: CGFloat) -> CGFloat? { - let centers = RosettaTab.interactionOrder.compactMap { tab -> CGFloat? in - tabFrames[tab]?.midX - } - - guard centers.count == RosettaTab.interactionOrder.count else { - return nil - } - - if centerX <= centers[0] { - return 0 - } - if centerX >= centers[centers.count - 1] { - return CGFloat(centers.count - 1) - } - - for index in 0 ..< centers.count - 1 { - let left = centers[index] - let right = centers[index + 1] - guard centerX >= left, centerX <= right else { - continue - } - let progress = (centerX - left) / max(1, right - left) - return CGFloat(index) + progress - } - - return nil - } - - private func badgeText(for tab: RosettaTab) -> String? { - badges.first(where: { $0.tab == tab })?.text - } - - private func isCoveredByLens(_ tab: RosettaTab) -> Bool { - interactionState?.isLifted == true && interactionState?.hoveredTab == tab - } - - private func lensDiameter(for tab: RosettaTab) -> CGFloat { - switch tab { - case .search: - return 88 - default: - return 104 - } - } -} - -// MARK: - Main Tabs Pill - -private extension RosettaTabBar { - var mainTabsPill: some View { - HStack(spacing: 0) { - ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in - TabItemView( - tab: tab, - isSelected: tab == visualSelectedTab, - isCoveredByLens: isCoveredByLens(tab), - badgeText: badgeText(for: tab) - ) - .tabFramePreference(tab: tab, in: Self.tabBarSpace) - } - } - .padding(.horizontal, 4) - .padding(.top, 3) - .padding(.bottom, 3) - .frame(height: 62) - .background { - mainPillGlass - } - } - - @ViewBuilder - var mainPillGlass: some View { - ZStack { - Capsule().fill(.ultraThinMaterial) - Capsule().fill(Color.black.opacity(0.34)) - Capsule().fill( - LinearGradient( - colors: [Color.white.opacity(0.08), .clear], - startPoint: .top, - endPoint: .bottom - ) - ).blendMode(.screen) - Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1) - Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5) - } - .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) - } - - @ViewBuilder - var liftedLensOverlay: some View { - if let state = interactionState, - state.isLifted, - let hoveredFrame = tabFrames[state.hoveredTab] - { - let diameter = lensDiameter(for: state.hoveredTab) - - ZStack { - lensBubble - LensTabContentView( - tab: state.hoveredTab, - badgeText: badgeText(for: state.hoveredTab) - ) - .padding(.top, state.hoveredTab == .search ? 0 : 8) - } - .frame(width: diameter, height: diameter) - .position(x: state.currentCenterX, y: hoveredFrame.midY - lensLiftOffset) - .shadow(color: .black.opacity(0.42), radius: 24, y: 15) - .shadow(color: Color.cyan.opacity(0.10), radius: 20, y: 1) - .allowsHitTesting(false) - .transition(.scale(scale: 0.86).combined(with: .opacity)) - .animation(.spring(response: 0.34, dampingFraction: 0.74), value: state.isLifted) - .zIndex(20) - } - } - - @ViewBuilder - var lensBubble: some View { - ZStack { - Circle().fill(.ultraThinMaterial) - Circle().fill(Color.black.opacity(0.38)) - Circle().fill( - LinearGradient( - colors: [Color.white.opacity(0.08), Color.white.opacity(0.01), .clear], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - Circle().stroke(Color.white.opacity(0.16), lineWidth: 1) - Circle().stroke(Color.white.opacity(0.06), lineWidth: 1).padding(1.6) - Circle().stroke( - AngularGradient( - colors: [ - Color.cyan.opacity(0.34), - Color.blue.opacity(0.28), - Color.pink.opacity(0.28), - Color.orange.opacity(0.30), - Color.yellow.opacity(0.20), - Color.cyan.opacity(0.34), - ], - center: .center - ), - lineWidth: 1.1 - ).blendMode(.screen) - } - .compositingGroup() - } -} - -// MARK: - Tab Item - -private struct TabItemView: View { - let tab: RosettaTab - let isSelected: Bool - let isCoveredByLens: Bool - let badgeText: String? - - var body: some View { - VStack(spacing: 1) { - ZStack(alignment: .topTrailing) { - Image(systemName: isSelected ? tab.selectedIcon : tab.icon) - .font(.system(size: 22)) - .foregroundStyle(tabColor) - .frame(height: 30) - - if let badgeText { - Text(badgeText) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white) - .padding(.horizontal, badgeText.count > 2 ? 4 : 0) - .frame(minWidth: 18, minHeight: 18) - .background(Capsule().fill(RosettaColors.error)) - .offset(x: 10, y: -4) - } - } - - Text(tab.label) - .font(.system(size: 10, weight: isSelected ? .bold : .medium)) - .foregroundStyle(tabColor) - } - .frame(maxWidth: .infinity) - .padding(14) - .opacity(isCoveredByLens ? 0.07 : 1) - .animation(.easeInOut(duration: 0.14), value: isCoveredByLens) - .accessibilityLabel(tab.label) - .accessibilityAddTraits(isSelected ? .isSelected : []) - } - - private var tabColor: Color { - isSelected - ? RosettaColors.primaryBlue - : RosettaColors.adaptive( - light: Color(hex: 0x404040), - dark: Color(hex: 0x8E8E93) - ) - } -} - -// MARK: - Search Pill - -private extension RosettaTabBar { - var searchPill: some View { - SearchPillView( - isSelected: visualSelectedTab == .search, - isCoveredByLens: isCoveredByLens(.search) - ) - .tabFramePreference(tab: .search, in: Self.tabBarSpace) - } -} - -private struct SearchPillView: View { - let isSelected: Bool - let isCoveredByLens: Bool - - var body: some View { - Image(systemName: "magnifyingglass") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle( - isSelected - ? RosettaColors.primaryBlue - : RosettaColors.adaptive( - light: Color(hex: 0x404040), - dark: Color(hex: 0x8E8E93) - ) - ) - .frame(width: 62, height: 62) - .opacity(isCoveredByLens ? 0.08 : 1) - .animation(.easeInOut(duration: 0.14), value: isCoveredByLens) - .background { searchPillGlass } - .accessibilityLabel("Search") - .accessibilityAddTraits(isSelected ? .isSelected : []) - } - - @ViewBuilder - private var searchPillGlass: some View { - ZStack { - Circle().fill(.ultraThinMaterial) - Circle().fill(Color.black.opacity(0.34)) - Circle().fill( - LinearGradient( - colors: [Color.white.opacity(0.08), .clear], - startPoint: .top, - endPoint: .bottom - ) - ).blendMode(.screen) - Circle().stroke(Color.white.opacity(0.12), lineWidth: 1) - Circle().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5) - } - .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) - } -} - -private struct LensTabContentView: View { - let tab: RosettaTab - let badgeText: String? - - var body: some View { - if tab == .search { - Image(systemName: "magnifyingglass") - .font(.system(size: 29, weight: .semibold)) - .foregroundStyle(RosettaColors.primaryBlue) - } else { - VStack(spacing: 3) { + onTabSelected?(tab) + } label: { + VStack(spacing: 2) { ZStack(alignment: .topTrailing) { - Image(systemName: tab.selectedIcon) - .font(.system(size: 30)) - .foregroundStyle(.white) - .frame(height: 36) + Image(systemName: isEffectivelySelected ? tab.selectedIcon : tab.icon) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(tint) + .frame(height: 28) - if let badgeText { - Text(badgeText) - .font(.system(size: 10, weight: .medium)) + if let badge { + Text(badge) + .font(.system(size: 10, weight: .bold)) .foregroundStyle(.white) - .padding(.horizontal, badgeText.count > 2 ? 5 : 0) - .frame(minWidth: 20, minHeight: 20) + .padding(.horizontal, badge.count > 2 ? 4 : 0) + .frame(minWidth: 18, minHeight: 18) .background(Capsule().fill(RosettaColors.error)) - .offset(x: 16, y: -9) + .offset(x: 10, y: -4) } } Text(tab.label) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.primaryBlue) + .font(.system(size: 10, weight: isEffectivelySelected ? .bold : .medium)) + .foregroundStyle(tint) } + .frame(minWidth: 66, maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .accessibilityLabel(tab.label) + .accessibilityAddTraits(isEffectivelySelected ? .isSelected : []) + } + + // MARK: - Color Interpolation + + /// Pre-computed RGBA to avoid creating UIColor on every drag frame. + private static let unselectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + UIColor(TabBarColors.unselectedTint).getRed(&r, green: &g, blue: &b, alpha: &a) + return (r, g, b, a) + }() + + private static let selectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + UIColor(TabBarColors.selectedTint).getRed(&r, green: &g, blue: &b, alpha: &a) + return (r, g, b, a) + }() + + private func tintColor(blend: CGFloat) -> Color { + let t = blend.clamped(to: 0...1) + let (fr, fg, fb, fa) = Self.unselectedRGBA + let (tr, tg, tb, ta) = Self.selectedRGBA + return Color( + red: fr + (tr - fr) * t, + green: fg + (tg - fg) * t, + blue: fb + (tb - fb) * t, + opacity: fa + (ta - fa) * t + ) + } +} + +// MARK: - Shadow (iOS < 26 only) + +/// Glass has built-in depth on iOS 26+, so shadow is only needed on older versions. +private struct TabBarShadowModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + } else { + content + .shadow(color: Color.black.opacity(0.12), radius: 20, y: 8) } } } -// MARK: - Geometry Helpers +// MARK: - Comparable Clamping -private struct TabPressInteraction { - let id: UUID - let startTab: RosettaTab - let startCenterX: CGFloat - var currentCenterX: CGFloat - var hoveredTab: RosettaTab - var isLifted: Bool -} - -private struct TabFramePreferenceKey: PreferenceKey { - static var defaultValue: [RosettaTab: CGRect] = [:] - - static func reduce(value: inout [RosettaTab: CGRect], nextValue: () -> [RosettaTab: CGRect]) { - value.merge(nextValue(), uniquingKeysWith: { _, new in new }) - } -} - -private extension View { - func tabFramePreference(tab: RosettaTab, in coordinateSpace: String) -> some View { - background { - GeometryReader { proxy in - Color.clear.preference( - key: TabFramePreferenceKey.self, - value: [tab: proxy.frame(in: .named(coordinateSpace))] - ) - } - } +private extension Comparable { + func clamped(to range: ClosedRange) -> Self { + min(max(self, range.lowerBound), range.upperBound) } } @@ -533,12 +351,6 @@ private extension View { #Preview { ZStack(alignment: .bottom) { Color.black.ignoresSafeArea() - - RosettaTabBar( - selectedTab: .chats, - badges: [ - TabBadge(tab: .chats, text: "7"), - ] - ) + RosettaTabBar(selectedTab: .chats) } } diff --git a/Rosetta/Features/Auth/AuthCoordinator.swift b/Rosetta/Features/Auth/AuthCoordinator.swift index b55fd5b..dccc115 100644 --- a/Rosetta/Features/Auth/AuthCoordinator.swift +++ b/Rosetta/Features/Auth/AuthCoordinator.swift @@ -2,7 +2,7 @@ import SwiftUI // MARK: - Auth Screen Enum -enum AuthScreen: Equatable { +enum AuthScreen: Hashable { case welcome case seedPhrase case confirmSeed @@ -16,69 +16,12 @@ struct AuthCoordinator: View { let onAuthComplete: () -> Void var onBackToUnlock: (() -> Void)? - @State private var currentScreen: AuthScreen = .welcome + @State private var path = NavigationPath() @State private var seedPhrase: [String] = [] @State private var isImportMode = false - @State private var navigationDirection: NavigationDirection = .forward - @State private var swipeOffset: CGFloat = 0 - - private var canSwipeBack: Bool { - currentScreen != .welcome - } var body: some View { - GeometryReader { geometry in - let screenWidth = geometry.size.width - - ZStack { - RosettaColors.authBackground - .ignoresSafeArea() - - // Previous screen — peeks from behind during swipe - if canSwipeBack { - previousScreenView - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background { RosettaColors.authBackground.ignoresSafeArea() } - .offset(x: -screenWidth * 0.3 + swipeOffset * 0.3) - .overlay { - Color.black - .opacity(swipeOffset > 0 - ? 0.5 * (1.0 - swipeOffset / screenWidth) - : 1.0) - .ignoresSafeArea() - } - .allowsHitTesting(false) - } - - // Current screen — slides right during swipe - currentScreenView - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background { RosettaColors.authBackground.ignoresSafeArea() } - .transition(.move(edge: navigationDirection == .forward ? .trailing : .leading)) - .id(currentScreen) - .offset(x: swipeOffset) - } - .overlay(alignment: .leading) { - if canSwipeBack { - Color.clear - .frame(width: 20) - .contentShape(Rectangle()) - .padding(.top, 60) - .gesture(swipeBackGesture(screenWidth: screenWidth)) - } - } - .preferredColorScheme(.dark) - } - } -} - -// MARK: - Screen Views - -private extension AuthCoordinator { - @ViewBuilder - var currentScreenView: some View { - switch currentScreen { - case .welcome: + NavigationStack(path: $path) { WelcomeView( onGenerateSeed: { navigateTo(.seedPhrase) }, onImportSeed: { @@ -87,22 +30,40 @@ private extension AuthCoordinator { }, onBack: onBackToUnlock ) + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: AuthScreen.self) { screen in + destinationView(for: screen) + .toolbar(.hidden, for: .navigationBar) + } + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Screen Views + +private extension AuthCoordinator { + @ViewBuilder + func destinationView(for screen: AuthScreen) -> some View { + switch screen { + case .welcome: + EmptyView() case .seedPhrase: SeedPhraseView( seedPhrase: $seedPhrase, onContinue: { navigateTo(.confirmSeed) }, - onBack: { navigateBack(to: .welcome) } + onBack: { navigateBack() } ) case .confirmSeed: ConfirmSeedPhraseView( - seedPhrase: seedPhrase, + seedPhrase: $seedPhrase, onConfirmed: { isImportMode = false navigateTo(.setPassword) }, - onBack: { navigateBack(to: .seedPhrase) } + onBack: { navigateBack() } ) case .importSeed: @@ -112,113 +73,33 @@ private extension AuthCoordinator { isImportMode = true navigateTo(.setPassword) }, - onBack: { navigateBack(to: .welcome) } + onBack: { navigateBack() } ) case .setPassword: SetPasswordView( - seedPhrase: seedPhrase, + seedPhrase: $seedPhrase, isImportMode: isImportMode, onAccountCreated: onAuthComplete, - onBack: { - if isImportMode { - navigateBack(to: .importSeed) - } else { - navigateBack(to: .confirmSeed) - } - } + onBack: { navigateBack() } ) } } - - @ViewBuilder - var previousScreenView: some View { - switch currentScreen { - case .welcome: - EmptyView() - case .seedPhrase: - WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock) - case .confirmSeed: - SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {}) - case .importSeed: - WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock) - case .setPassword: - if isImportMode { - ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {}) - } else { - ConfirmSeedPhraseView(seedPhrase: seedPhrase, onConfirmed: {}, onBack: {}) - } - } - } } -// MARK: - Transitions (kept minimal — pure slide, no opacity, to avoid flash) - // MARK: - Navigation private extension AuthCoordinator { func navigateTo(_ screen: AuthScreen) { - navigationDirection = .forward - withAnimation(.spring(response: 0.45, dampingFraction: 0.92)) { - currentScreen = screen - } + path.append(screen) } - func navigateBack(to screen: AuthScreen) { - navigationDirection = .backward - withAnimation(.spring(response: 0.4, dampingFraction: 0.95)) { - currentScreen = screen - } + func navigateBack() { + guard !path.isEmpty else { return } + path.removeLast() } } -// MARK: - Swipe Back Gesture - -private extension AuthCoordinator { - func swipeBackGesture(screenWidth: CGFloat) -> some Gesture { - DragGesture(minimumDistance: 10) - .onChanged { value in - swipeOffset = max(value.translation.width, 0) - } - .onEnded { value in - let shouldGoBack = value.translation.width > 100 - || value.predictedEndTranslation.width > 200 - - if shouldGoBack { - withAnimation(.easeOut(duration: 0.25)) { - swipeOffset = screenWidth - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - swipeOffset = 0 - navigationDirection = .backward - currentScreen = backDestination - } - } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { - swipeOffset = 0 - } - } - } - } - - var backDestination: AuthScreen { - switch currentScreen { - case .welcome: return .welcome - case .seedPhrase: return .welcome - case .confirmSeed: return .seedPhrase - case .importSeed: return .welcome - case .setPassword: return isImportMode ? .importSeed : .confirmSeed - } - } -} - -// MARK: - Navigation Direction - -private enum NavigationDirection { - case forward - case backward -} - #Preview { AuthCoordinator(onAuthComplete: {}) } diff --git a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift index 5de8575..dc9f77e 100644 --- a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ConfirmSeedPhraseView: View { - let seedPhrase: [String] + @Binding var seedPhrase: [String] let onConfirmed: () -> Void let onBack: () -> Void @@ -13,6 +13,7 @@ struct ConfirmSeedPhraseView: View { private let confirmPositions = [1, 4, 8, 11] private var allCorrect: Bool { + guard seedPhrase.count >= 12 else { return false } for (inputIndex, seedIndex) in confirmPositions.enumerated() { let input = confirmationInputs[inputIndex].lowercased().trimmingCharacters(in: .whitespaces) if input != seedPhrase[seedIndex].lowercased() { return false } @@ -24,26 +25,31 @@ struct ConfirmSeedPhraseView: View { VStack(spacing: 0) { AuthNavigationBar(onBack: onBack) - ScrollView(showsIndicators: false) { - VStack(spacing: 24) { - headerSection - pasteButton - pasteSuccessMessage - wordGrid - errorMessage + if seedPhrase.count >= 12 { + ScrollView(showsIndicators: false) { + VStack(spacing: 24) { + headerSection + pasteButton + pasteSuccessMessage + wordGrid + errorMessage + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 100) } - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 100) - } - .scrollDismissesKeyboard(.interactively) - .onTapGesture(count: 1) { focusedInputIndex = nil } - .simultaneousGesture(TapGesture().onEnded {}) + .scrollDismissesKeyboard(.interactively) + .onTapGesture(count: 1) { focusedInputIndex = nil } + .simultaneousGesture(TapGesture().onEnded {}) - confirmButton - .padding(.horizontal, 24) - .padding(.bottom, 16) + confirmButton + .padding(.horizontal, 24) + .padding(.bottom, 16) + } else { + Spacer() + } } + .background { RosettaColors.authBackground.ignoresSafeArea() } } } @@ -128,7 +134,7 @@ private extension ConfirmSeedPhraseView { .foregroundStyle(RosettaColors.numberGray) .frame(width: 28, alignment: .trailing) - TextField("enter word", text: $confirmationInputs[inputIndex]) + TextField("enter", text: $confirmationInputs[inputIndex]) .font(.system(size: 17, weight: .semibold, design: .monospaced)) .foregroundStyle(.white) .autocorrectionDisabled() @@ -281,8 +287,8 @@ private extension ConfirmSeedPhraseView { #Preview { ConfirmSeedPhraseView( - seedPhrase: ["abandon", "ability", "able", "about", "above", "absent", - "absorb", "abstract", "absurd", "abuse", "access", "accident"], + seedPhrase: .constant(["abandon", "ability", "able", "about", "above", "absent", + "absorb", "abstract", "absurd", "abuse", "access", "accident"]), onConfirmed: {}, onBack: {} ) diff --git a/Rosetta/Features/Auth/ImportSeedPhraseView.swift b/Rosetta/Features/Auth/ImportSeedPhraseView.swift index a720f8a..4598762 100644 --- a/Rosetta/Features/Auth/ImportSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ImportSeedPhraseView.swift @@ -37,6 +37,7 @@ struct ImportSeedPhraseView: View { .padding(.horizontal, 24) .padding(.bottom, 16) } + .background { RosettaColors.authBackground.ignoresSafeArea() } } } diff --git a/Rosetta/Features/Auth/SeedPhraseView.swift b/Rosetta/Features/Auth/SeedPhraseView.swift index 79310f2..1c36183 100644 --- a/Rosetta/Features/Auth/SeedPhraseView.swift +++ b/Rosetta/Features/Auth/SeedPhraseView.swift @@ -37,6 +37,7 @@ struct SeedPhraseView: View { .padding(.horizontal, 24) .padding(.bottom, 16) } + .background { RosettaColors.authBackground.ignoresSafeArea() } .onAppear(perform: generateSeedPhraseIfNeeded) } } @@ -117,21 +118,12 @@ private struct SeedCardStyle: ViewModifier { let color: Color func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12)) - } else { - content - .background { - RoundedRectangle(cornerRadius: 12) - .fill(color.opacity(0.12)) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(color.opacity(0.18), lineWidth: 0.5) - } - } - .clipShape(RoundedRectangle(cornerRadius: 12)) - } + content + .background { + RoundedRectangle(cornerRadius: 14) + .fill(color.opacity(0.15)) + } + .clipShape(RoundedRectangle(cornerRadius: 14)) } } @@ -153,7 +145,7 @@ private extension SeedPhraseView { .font(.system(size: 15, weight: .medium)) } .foregroundStyle(showCopiedToast ? RosettaColors.success : RosettaColors.primaryBlue) - .contentTransition(.symbolEffect(.replace)) + .animation(.easeInOut(duration: 0.2), value: showCopiedToast) } .accessibilityLabel(showCopiedToast ? "Copied to clipboard" : "Copy seed phrase to clipboard") } diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index 8074df1..ee9c9fd 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -1,7 +1,7 @@ import SwiftUI struct SetPasswordView: View { - let seedPhrase: [String] + @Binding var seedPhrase: [String] let isImportMode: Bool let onAccountCreated: () -> Void let onBack: () -> Void @@ -72,6 +72,7 @@ struct SetPasswordView: View { .padding(.horizontal, 24) .padding(.bottom, 16) } + .background { RosettaColors.authBackground.ignoresSafeArea() } .ignoresSafeArea(.keyboard) } } @@ -278,8 +279,8 @@ private extension SetPasswordView { #Preview { SetPasswordView( - seedPhrase: ["abandon", "ability", "able", "about", "above", "absent", - "absorb", "abstract", "absurd", "abuse", "access", "accident"], + seedPhrase: .constant(["abandon", "ability", "able", "about", "above", "absent", + "absorb", "abstract", "absurd", "abuse", "access", "accident"]), isImportMode: false, onAccountCreated: {}, onBack: {} diff --git a/Rosetta/Features/Auth/WelcomeView.swift b/Rosetta/Features/Auth/WelcomeView.swift index 0dd2ee0..9cb782f 100644 --- a/Rosetta/Features/Auth/WelcomeView.swift +++ b/Rosetta/Features/Auth/WelcomeView.swift @@ -49,6 +49,7 @@ struct WelcomeView: View { .accessibilityLabel("Back") } } + .background { RosettaColors.authBackground.ignoresSafeArea() } .onAppear { guard !isVisible else { return } withAnimation(.easeOut(duration: 0.5)) { isVisible = true } diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift index df56522..c43cb88 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -103,15 +103,8 @@ final class ChatListViewModel: ObservableObject { self.serverSearchResults = packet.users self.isServerSearching = false Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)") - for user in packet.users { - DialogRepository.shared.updateUserInfo( - publicKey: user.publicKey, - title: user.title, - username: user.username, - verified: user.verified, - online: user.online - ) - } + // Note: DialogRepository.updateUserInfo is handled by + // SessionManager.setupUserInfoSearchHandler — avoid duplicate mutations. } } } diff --git a/Rosetta/Features/Onboarding/OnboardingPager.swift b/Rosetta/Features/Onboarding/OnboardingPager.swift index 484c603..a663adf 100644 --- a/Rosetta/Features/Onboarding/OnboardingPager.swift +++ b/Rosetta/Features/Onboarding/OnboardingPager.swift @@ -9,7 +9,19 @@ struct OnboardingPager: UIViewControllerRepresentable { let count: Int let buildPage: (Int) -> Page - func makeCoordinator() -> Coordinator { Coordinator(self) } + func makeCoordinator() -> OnboardingPagerCoordinator { + OnboardingPagerCoordinator( + currentIndex: currentIndex, + count: count, + currentIndexSetter: { [self] idx in self._currentIndex.wrappedValue = idx }, + continuousProgressSetter: { [self] val in self._continuousProgress.wrappedValue = val }, + buildControllers: { (0.. UIPageViewController { let vc = UIPageViewController( @@ -36,85 +48,95 @@ struct OnboardingPager: UIViewControllerRepresentable { } func updateUIViewController(_ vc: UIPageViewController, context: Context) { - context.coordinator.parent = self - } - - // MARK: - Coordinator - - final class Coordinator: NSObject, - UIPageViewControllerDataSource, - UIPageViewControllerDelegate, - UIScrollViewDelegate - { - var parent: OnboardingPager - let controllers: [UIHostingController] - var pageWidth: CGFloat = 0 - private var pendingIndex: Int = 0 - - init(_ parent: OnboardingPager) { - self.parent = parent - self.pendingIndex = parent.currentIndex - self.controllers = (0.. UIViewController? { - guard let idx = controllers.firstIndex(where: { $0 === vc }), idx > 0 else { return nil } - return controllers[idx - 1] - } - - func pageViewController( - _ pvc: UIPageViewController, - viewControllerAfter vc: UIViewController - ) -> UIViewController? { - guard let idx = controllers.firstIndex(where: { $0 === vc }), - idx < parent.count - 1 else { return nil } - return controllers[idx + 1] - } - - // MARK: Delegate - - func pageViewController( - _ pvc: UIPageViewController, - willTransitionTo pendingVCs: [UIViewController] - ) { - if let vc = pendingVCs.first, - let idx = controllers.firstIndex(where: { $0 === vc }) { - pendingIndex = idx - } - } - - func pageViewController( - _ pvc: UIPageViewController, - didFinishAnimating finished: Bool, - previousViewControllers: [UIViewController], - transitionCompleted completed: Bool - ) { - guard completed, - let current = pvc.viewControllers?.first, - let idx = controllers.firstIndex(where: { $0 === current }) else { return } - parent.currentIndex = idx - } - - // MARK: ScrollView — real-time progress - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let w = scrollView.frame.width - guard w > 0 else { return } - // UIPageViewController's scroll view rests at x = width. - // Dragging left (next page) increases x; dragging right decreases. - let offsetFromCenter = scrollView.contentOffset.x - w - let fraction = offsetFromCenter / w - let progress = CGFloat(parent.currentIndex) + fraction - parent.continuousProgress = max(0, min(CGFloat(parent.count - 1), progress)) - } + context.coordinator.count = count + context.coordinator.currentIndex = currentIndex + context.coordinator.currentIndexSetter = { [self] idx in self._currentIndex.wrappedValue = idx } + context.coordinator.continuousProgressSetter = { [self] val in self._continuousProgress.wrappedValue = val } + } +} + +// MARK: - Coordinator (non-generic to avoid Swift compiler crash in Release optimiser) + +final class OnboardingPagerCoordinator: NSObject, + UIPageViewControllerDataSource, + UIPageViewControllerDelegate, + UIScrollViewDelegate +{ + let controllers: [UIViewController] + var count: Int + var currentIndex: Int + var currentIndexSetter: (Int) -> Void + var continuousProgressSetter: (CGFloat) -> Void + var pageWidth: CGFloat = 0 + private var pendingIndex: Int = 0 + + init( + currentIndex: Int, + count: Int, + currentIndexSetter: @escaping (Int) -> Void, + continuousProgressSetter: @escaping (CGFloat) -> Void, + buildControllers: () -> [UIViewController] + ) { + self.currentIndex = currentIndex + self.count = count + self.currentIndexSetter = currentIndexSetter + self.continuousProgressSetter = continuousProgressSetter + self.pendingIndex = currentIndex + self.controllers = buildControllers() + } + + // MARK: DataSource + + func pageViewController( + _ pvc: UIPageViewController, + viewControllerBefore vc: UIViewController + ) -> UIViewController? { + guard let idx = controllers.firstIndex(where: { $0 === vc }), idx > 0 else { return nil } + return controllers[idx - 1] + } + + func pageViewController( + _ pvc: UIPageViewController, + viewControllerAfter vc: UIViewController + ) -> UIViewController? { + guard let idx = controllers.firstIndex(where: { $0 === vc }), + idx < count - 1 else { return nil } + return controllers[idx + 1] + } + + // MARK: Delegate + + func pageViewController( + _ pvc: UIPageViewController, + willTransitionTo pendingVCs: [UIViewController] + ) { + if let vc = pendingVCs.first, + let idx = controllers.firstIndex(where: { $0 === vc }) { + pendingIndex = idx + } + } + + func pageViewController( + _ pvc: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + guard completed, + let current = pvc.viewControllers?.first, + let idx = controllers.firstIndex(where: { $0 === current }) else { return } + currentIndex = idx + currentIndexSetter(idx) + } + + // MARK: ScrollView — real-time progress + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let w = scrollView.frame.width + guard w > 0 else { return } + let offsetFromCenter = scrollView.contentOffset.x - w + let fraction = offsetFromCenter / w + let progress = CGFloat(currentIndex) + fraction + continuousProgressSetter(max(0, min(CGFloat(count - 1), progress))) } } diff --git a/Rosetta/Resources/Lottie/Images/back_5.png b/Rosetta/Resources/Lottie/Images/back_5.png new file mode 100644 index 0000000..3ec7b78 Binary files /dev/null and b/Rosetta/Resources/Lottie/Images/back_5.png differ