diff --git a/.gitignore b/.gitignore index e356d0c..3716042 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,3 @@ Package.resolved .DS_Store *.swp *~ -.claude.local.md diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index eb605a3..c7abfe2 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 = 4; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -280,7 +280,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.0; 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 = 4; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -317,7 +317,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.0; 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 373a139..f1f4a59 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 - } + dialog.lastMessage = decryptedText + dialog.lastMessageTimestamp = normalizeTimestamp(packet.timestamp) + dialog.lastMessageFromMe = fromMe + dialog.lastMessageDelivered = fromMe ? .waiting : .delivered if fromMe { dialog.iHaveSent = true - } else if isNewMessage && !isDialogActive { - // Only increment unread for genuinely new incoming messages - // when the user is NOT currently viewing this dialog. + } else { dialog.unreadCount += 1 } @@ -208,9 +188,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: server sends inverted values (0 = online, 1 = offline), -1 = not provided + // online: 0 = offline, 1 = online, -1 = not provided (don't update) 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 b0c162f..0fbf0b6 100644 --- a/Rosetta/Core/Network/Protocol/Packets/PacketOnlineState.swift +++ b/Rosetta/Core/Network/Protocol/Packets/PacketOnlineState.swift @@ -21,9 +21,8 @@ struct PacketOnlineState: Packet { var list: [OnlineStateEntry] = [] for _ in 0.. Void)? var onDisconnected: ((Error?) -> Void)? @@ -28,9 +23,6 @@ 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) } @@ -96,7 +88,6 @@ 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?() @@ -148,17 +139,10 @@ 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 10 seconds (attempt \(self?.reconnectAttempts ?? 0)/\(Self.maxReconnectAttempts))...") - try? await Task.sleep(nanoseconds: Self.reconnectInterval) + Self.logger.info("Reconnecting in 5 seconds...") + try? await Task.sleep(nanoseconds: 5_000_000_000) 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 f1f18ad..d85da30 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -410,7 +410,6 @@ 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, @@ -426,11 +425,7 @@ final class SessionManager { } DialogRepository.shared.updateFromMessage( - packet, - myPublicKey: myKey, - decryptedText: text, - isNewMessage: !wasKnownBefore, - isDialogActive: dialogIsActive + packet, myPublicKey: myKey, decryptedText: text ) MessageRepository.shared.upsertFromMessagePacket( packet, diff --git a/Rosetta/DesignSystem/Components/AvatarView.swift b/Rosetta/DesignSystem/Components/AvatarView.swift index 11ed8bc..26dcdc9 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: .bottomTrailing) { + .overlay(alignment: .bottomLeading) { if isOnline { Circle() - .fill(RosettaColors.figmaBlue) - .frame(width: size * 0.19, height: size * 0.19) + .fill(Color(hex: 0x4CD964)) + .frame(width: badgeSize, height: badgeSize) .overlay { Circle() - .stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.04) + .stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.05) } - .offset(x: 0, y: -size * 0.06) + .offset(x: -1, y: 1) } } .accessibilityLabel(isSavedMessages ? "Saved Messages" : initials) diff --git a/Rosetta/DesignSystem/Components/ButtonStyles.swift b/Rosetta/DesignSystem/Components/ButtonStyles.swift index 95ee1c1..d7e28ef 100644 --- a/Rosetta/DesignSystem/Components/ButtonStyles.swift +++ b/Rosetta/DesignSystem/Components/ButtonStyles.swift @@ -19,9 +19,20 @@ struct GlassBackButton: View { .clipShape(Circle()) } + @ViewBuilder private var glassCircle: some View { - Circle() - .fill(Color.white.opacity(0.15)) + 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) + } + } } } @@ -35,9 +46,19 @@ struct RosettaPrimaryButtonStyle: ButtonStyle { } func makeBody(configuration: Configuration) -> some View { - configuration.label - .background { glassBackground(isPressed: configuration.isPressed) } - .clipShape(Capsule()) + 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()) + } + } .scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0) .animation(.easeOut(duration: 0.15), value: configuration.isPressed) .allowsHitTesting(isEnabled) @@ -46,6 +67,20 @@ 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 f01283f..3265eb9 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -16,13 +16,25 @@ struct GlassCard: View { } var body: some View { - content() - .background { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(RosettaColors.adaptive( - light: Color.black.opacity(fillOpacity), - dark: Color.white.opacity(fillOpacity) - )) - } + 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) + } + } + } } } diff --git a/Rosetta/DesignSystem/Components/GlassModifier.swift b/Rosetta/DesignSystem/Components/GlassModifier.swift index b769c7e..0a16bf4 100644 --- a/Rosetta/DesignSystem/Components/GlassModifier.swift +++ b/Rosetta/DesignSystem/Components/GlassModifier.swift @@ -1,45 +1,81 @@ import SwiftUI -// MARK: - Glass Modifier +// MARK: - Glass Modifier (5-layer glass that works on black) // -// Solid adaptive background — no glass or material effects +// 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 struct GlassModifier: ViewModifier { let cornerRadius: CGFloat - private var fillColor: Color { - RosettaColors.adaptive( - light: Color(hex: 0xF2F2F7), - dark: Color(hex: 0x1C1C1E) - ) - } - func body(content: Content) -> some View { - content - .background { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(fillColor) - } + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + 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) + } + } } } // MARK: - View Extension extension View { - /// Solid background with rounded corners. + /// 5-layer frosted glass background. func glass(cornerRadius: CGFloat = 24) -> some View { modifier(GlassModifier(cornerRadius: cornerRadius)) } - /// Solid capsule background — convenience for pill-shaped elements. + /// Glass capsule — convenience for pill-shaped elements. + @ViewBuilder func glassCapsule() -> some View { - background { - Capsule().fill( - RosettaColors.adaptive( - light: Color(hex: 0xF2F2F7), - dark: Color(hex: 0x1C1C1E) - ) - ) + 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) + } } } } diff --git a/Rosetta/DesignSystem/Components/GlassModifiers.swift b/Rosetta/DesignSystem/Components/GlassModifiers.swift index c953437..0db332e 100644 --- a/Rosetta/DesignSystem/Components/GlassModifiers.swift +++ b/Rosetta/DesignSystem/Components/GlassModifiers.swift @@ -5,8 +5,12 @@ 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 { - content - .toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar) + if #available(iOS 26, *) { + content + } else { + content + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + } } } @@ -21,7 +25,12 @@ extension View { /// Applies glassmorphism capsule effect on iOS 26+. struct GlassSearchBarModifier: ViewModifier { func body(content: Content) -> some View { - content + if #available(iOS 26, *) { + content + .glassEffect(.regular, in: .capsule) + } else { + content + } } } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index cff59fc..1a3ebf4 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -52,297 +52,479 @@ 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] = [] - private let allTabs = RosettaTab.interactionOrder - private let tabCount = RosettaTab.interactionOrder.count + @State private var tabFrames: [RosettaTab: CGRect] = [:] + @State private var interactionState: TabPressInteraction? - // Drag state - @State private var isDragging = false - @State private var dragFractional: CGFloat = 0 - @State private var dragStartIndex: CGFloat = 0 - - // 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 static let tabBarSpace = "RosettaTabBarSpace" + private let lensLiftOffset: CGFloat = 12 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] - ) - } - ) - } - } - .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) - ) - } - } - .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() } } + interactiveTabBarContent + .padding(.horizontal, 25) + .padding(.top, 4) } - /// 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) + private var interactiveTabBarContent: some View { + tabBarContent + .coordinateSpace(name: Self.tabBarSpace) + .onPreferenceChange(TabFramePreferenceKey.self) { frames in + tabFrames = frames } - } - .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 - )) + .contentShape(Rectangle()) + .gesture(tabSelectionGesture) + .overlay(alignment: .topLeading) { + liftedLensOverlay } - .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) + .onDisappear { + interactionState = nil onSwipeStateChanged?(nil) } } - private var totalTabWidth: CGFloat { - tabWidths.values.reduce(0, +) + private var tabBarContent: some View { + HStack(spacing: 8) { + mainTabsPill + searchPill + } } - // MARK: - Tab Content + private var visualSelectedTab: RosettaTab { + if let interactionState, interactionState.isLifted { + return interactionState.hoveredTab + } + return selectedTab + } - 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 var tabSelectionGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace)) + .onChanged(handleGestureChanged) + .onEnded(handleGestureEnded) + } - return Button { - guard !isDragging else { return } + 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 + ) UIImpactFeedbackGenerator(style: .light).impactOccurred() - onTabSelected?(tab) - } label: { - VStack(spacing: 2) { - ZStack(alignment: .topTrailing) { - Image(systemName: isEffectivelySelected ? tab.selectedIcon : tab.icon) - .font(.system(size: 22, weight: .regular)) - .foregroundStyle(tint) - .frame(height: 28) + interactionState = state + publishSwipeState(for: state) + return + } - if let badge { - Text(badge) - .font(.system(size: 10, weight: .bold)) + 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) { + ZStack(alignment: .topTrailing) { + Image(systemName: tab.selectedIcon) + .font(.system(size: 30)) + .foregroundStyle(.white) + .frame(height: 36) + + if let badgeText { + Text(badgeText) + .font(.system(size: 10, weight: .medium)) .foregroundStyle(.white) - .padding(.horizontal, badge.count > 2 ? 4 : 0) - .frame(minWidth: 18, minHeight: 18) + .padding(.horizontal, badgeText.count > 2 ? 5 : 0) + .frame(minWidth: 20, minHeight: 20) .background(Capsule().fill(RosettaColors.error)) - .offset(x: 10, y: -4) + .offset(x: 16, y: -9) } } Text(tab.label) - .font(.system(size: 10, weight: isEffectivelySelected ? .bold : .medium)) - .foregroundStyle(tint) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.primaryBlue) } - .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: - Comparable Clamping +// MARK: - Geometry Helpers -private extension Comparable { - func clamped(to range: ClosedRange) -> Self { - min(max(self, range.lowerBound), range.upperBound) +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))] + ) + } + } } } @@ -351,6 +533,12 @@ private extension Comparable { #Preview { ZStack(alignment: .bottom) { Color.black.ignoresSafeArea() - RosettaTabBar(selectedTab: .chats) + + RosettaTabBar( + selectedTab: .chats, + badges: [ + TabBadge(tab: .chats, text: "7"), + ] + ) } } diff --git a/Rosetta/Features/Auth/AuthCoordinator.swift b/Rosetta/Features/Auth/AuthCoordinator.swift index dccc115..b55fd5b 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: Hashable { +enum AuthScreen: Equatable { case welcome case seedPhrase case confirmSeed @@ -16,12 +16,69 @@ struct AuthCoordinator: View { let onAuthComplete: () -> Void var onBackToUnlock: (() -> Void)? - @State private var path = NavigationPath() + @State private var currentScreen: AuthScreen = .welcome @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 { - NavigationStack(path: $path) { + 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: WelcomeView( onGenerateSeed: { navigateTo(.seedPhrase) }, onImportSeed: { @@ -30,40 +87,22 @@ struct AuthCoordinator: View { }, 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() } + onBack: { navigateBack(to: .welcome) } ) case .confirmSeed: ConfirmSeedPhraseView( - seedPhrase: $seedPhrase, + seedPhrase: seedPhrase, onConfirmed: { isImportMode = false navigateTo(.setPassword) }, - onBack: { navigateBack() } + onBack: { navigateBack(to: .seedPhrase) } ) case .importSeed: @@ -73,33 +112,113 @@ private extension AuthCoordinator { isImportMode = true navigateTo(.setPassword) }, - onBack: { navigateBack() } + onBack: { navigateBack(to: .welcome) } ) case .setPassword: SetPasswordView( - seedPhrase: $seedPhrase, + seedPhrase: seedPhrase, isImportMode: isImportMode, onAccountCreated: onAuthComplete, - onBack: { navigateBack() } + onBack: { + if isImportMode { + navigateBack(to: .importSeed) + } else { + navigateBack(to: .confirmSeed) + } + } ) } } + + @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) { - path.append(screen) + navigationDirection = .forward + withAnimation(.spring(response: 0.45, dampingFraction: 0.92)) { + currentScreen = screen + } } - func navigateBack() { - guard !path.isEmpty else { return } - path.removeLast() + func navigateBack(to screen: AuthScreen) { + navigationDirection = .backward + withAnimation(.spring(response: 0.4, dampingFraction: 0.95)) { + currentScreen = screen + } } } +// 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 dc9f77e..5de8575 100644 --- a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ConfirmSeedPhraseView: View { - @Binding var seedPhrase: [String] + let seedPhrase: [String] let onConfirmed: () -> Void let onBack: () -> Void @@ -13,7 +13,6 @@ 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 } @@ -25,31 +24,26 @@ struct ConfirmSeedPhraseView: View { VStack(spacing: 0) { AuthNavigationBar(onBack: onBack) - if seedPhrase.count >= 12 { - ScrollView(showsIndicators: false) { - VStack(spacing: 24) { - headerSection - pasteButton - pasteSuccessMessage - wordGrid - errorMessage - } - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 100) + ScrollView(showsIndicators: false) { + VStack(spacing: 24) { + headerSection + pasteButton + pasteSuccessMessage + wordGrid + errorMessage } - .scrollDismissesKeyboard(.interactively) - .onTapGesture(count: 1) { focusedInputIndex = nil } - .simultaneousGesture(TapGesture().onEnded {}) - - confirmButton - .padding(.horizontal, 24) - .padding(.bottom, 16) - } else { - Spacer() + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 100) } + .scrollDismissesKeyboard(.interactively) + .onTapGesture(count: 1) { focusedInputIndex = nil } + .simultaneousGesture(TapGesture().onEnded {}) + + confirmButton + .padding(.horizontal, 24) + .padding(.bottom, 16) } - .background { RosettaColors.authBackground.ignoresSafeArea() } } } @@ -134,7 +128,7 @@ private extension ConfirmSeedPhraseView { .foregroundStyle(RosettaColors.numberGray) .frame(width: 28, alignment: .trailing) - TextField("enter", text: $confirmationInputs[inputIndex]) + TextField("enter word", text: $confirmationInputs[inputIndex]) .font(.system(size: 17, weight: .semibold, design: .monospaced)) .foregroundStyle(.white) .autocorrectionDisabled() @@ -287,8 +281,8 @@ private extension ConfirmSeedPhraseView { #Preview { ConfirmSeedPhraseView( - seedPhrase: .constant(["abandon", "ability", "able", "about", "above", "absent", - "absorb", "abstract", "absurd", "abuse", "access", "accident"]), + seedPhrase: ["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 4598762..a720f8a 100644 --- a/Rosetta/Features/Auth/ImportSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ImportSeedPhraseView.swift @@ -37,7 +37,6 @@ 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 1c36183..79310f2 100644 --- a/Rosetta/Features/Auth/SeedPhraseView.swift +++ b/Rosetta/Features/Auth/SeedPhraseView.swift @@ -37,7 +37,6 @@ struct SeedPhraseView: View { .padding(.horizontal, 24) .padding(.bottom, 16) } - .background { RosettaColors.authBackground.ignoresSafeArea() } .onAppear(perform: generateSeedPhraseIfNeeded) } } @@ -118,12 +117,21 @@ private struct SeedCardStyle: ViewModifier { let color: Color func body(content: Content) -> some View { - content - .background { - RoundedRectangle(cornerRadius: 14) - .fill(color.opacity(0.15)) - } - .clipShape(RoundedRectangle(cornerRadius: 14)) + 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)) + } } } @@ -145,7 +153,7 @@ private extension SeedPhraseView { .font(.system(size: 15, weight: .medium)) } .foregroundStyle(showCopiedToast ? RosettaColors.success : RosettaColors.primaryBlue) - .animation(.easeInOut(duration: 0.2), value: showCopiedToast) + .contentTransition(.symbolEffect(.replace)) } .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 ee9c9fd..8074df1 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -1,7 +1,7 @@ import SwiftUI struct SetPasswordView: View { - @Binding var seedPhrase: [String] + let seedPhrase: [String] let isImportMode: Bool let onAccountCreated: () -> Void let onBack: () -> Void @@ -72,7 +72,6 @@ struct SetPasswordView: View { .padding(.horizontal, 24) .padding(.bottom, 16) } - .background { RosettaColors.authBackground.ignoresSafeArea() } .ignoresSafeArea(.keyboard) } } @@ -279,8 +278,8 @@ private extension SetPasswordView { #Preview { SetPasswordView( - seedPhrase: .constant(["abandon", "ability", "able", "about", "above", "absent", - "absorb", "abstract", "absurd", "abuse", "access", "accident"]), + seedPhrase: ["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 9cb782f..0dd2ee0 100644 --- a/Rosetta/Features/Auth/WelcomeView.swift +++ b/Rosetta/Features/Auth/WelcomeView.swift @@ -49,7 +49,6 @@ 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 c43cb88..df56522 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -103,8 +103,15 @@ final class ChatListViewModel: ObservableObject { self.serverSearchResults = packet.users self.isServerSearching = false Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)") - // Note: DialogRepository.updateUserInfo is handled by - // SessionManager.setupUserInfoSearchHandler — avoid duplicate mutations. + for user in packet.users { + DialogRepository.shared.updateUserInfo( + publicKey: user.publicKey, + title: user.title, + username: user.username, + verified: user.verified, + online: user.online + ) + } } } } diff --git a/Rosetta/Features/Onboarding/OnboardingPager.swift b/Rosetta/Features/Onboarding/OnboardingPager.swift index a663adf..484c603 100644 --- a/Rosetta/Features/Onboarding/OnboardingPager.swift +++ b/Rosetta/Features/Onboarding/OnboardingPager.swift @@ -9,19 +9,7 @@ struct OnboardingPager: UIViewControllerRepresentable { let count: Int let buildPage: (Int) -> Page - 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.. Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIPageViewController { let vc = UIPageViewController( @@ -48,95 +36,85 @@ struct OnboardingPager: UIViewControllerRepresentable { } func updateUIViewController(_ vc: UIPageViewController, context: Context) { - 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() + context.coordinator.parent = self } - // MARK: DataSource + // MARK: - Coordinator - 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] - } + final class Coordinator: NSObject, + UIPageViewControllerDataSource, + UIPageViewControllerDelegate, + UIScrollViewDelegate + { + var parent: OnboardingPager + let controllers: [UIHostingController] + var pageWidth: CGFloat = 0 + private var pendingIndex: Int = 0 - 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] - } + 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)) } } - - 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 deleted file mode 100644 index 3ec7b78..0000000 Binary files a/Rosetta/Resources/Lottie/Images/back_5.png and /dev/null differ