From b1f71c43f08eea0158a9bcb4601ed9c1b88945ee Mon Sep 17 00:00:00 2001 From: senseiGai Date: Mon, 9 Mar 2026 12:10:43 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD-=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0=20iOS=20<=2026:=20=D1=87?= =?UTF-8?q?=D1=91=D1=80=D0=BD=D1=8B=D0=B9=20=D1=84=D0=BE=D0=BD,=20blur=20?= =?UTF-8?q?=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20SplashView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/DialogRepository.swift | 8 +- Rosetta/Core/Services/SessionManager.swift | 38 +- .../DesignSystem/Components/AvatarView.swift | 2 +- .../Components/ButtonStyles.swift | 20 +- .../DesignSystem/Components/GlassCard.swift | 12 +- .../Components/GlassModifier.swift | 42 +- .../Components/GlassModifiers.swift | 8 +- .../Components/RosettaTabBar.swift | 700 +++++++----------- .../Features/Auth/ConfirmSeedPhraseView.swift | 2 +- .../Chats/ChatDetail/ChatDetailView.swift | 19 +- .../Chats/ChatList/ChatListViewModel.swift | 1 + .../Features/Chats/ChatList/ChatRowView.swift | 2 +- Rosetta/Features/MainTabView.swift | 29 +- .../Features/Onboarding/OnboardingView.swift | 4 +- Rosetta/Features/Settings/SettingsView.swift | 4 +- Rosetta/Features/Splash/SplashView.swift | 57 -- Rosetta/RosettaApp.swift | 37 +- 17 files changed, 352 insertions(+), 633 deletions(-) delete mode 100644 Rosetta/Features/Splash/SplashView.swift diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 843501f..c9ac606 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -106,7 +106,13 @@ final class DialogRepository { if fromMe { dialog.iHaveSent = true } else { - dialog.unreadCount += 1 + // Only increment unread count when the user is NOT viewing this dialog. + // If the dialog is active (ChatDetailView is open), the user sees messages + // immediately — incrementing here would race with markAsRead() and cause + // the badge to flicker under rapid incoming messages. + if !MessageRepository.shared.isDialogActive(opponentKey) { + dialog.unreadCount += 1 + } } dialogs[opponentKey] = dialog diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index c102cf4..70a3b7c 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -32,6 +32,7 @@ final class SessionManager { private var pendingReadReceiptKeys: Set = [] private var lastReadReceiptSentAt: [String: Int64] = [:] private var requestedUserInfoKeys: Set = [] + private var onlineSubscribedKeys: Set = [] private var pendingOutgoingRetryTasks: [String: Task] = [:] private var pendingOutgoingPackets: [String: PacketMessage] = [:] private var pendingOutgoingAttempts: [String: Int] = [:] @@ -337,14 +338,21 @@ final class SessionManager { Self.logger.debug("Skipping UserInfo — no profile data to send") } - // Android parity: request message synchronization after authentication. + // Reset sync state — if a previous connection dropped mid-sync, + // syncRequestInFlight would stay true and block all future syncs. + self.syncRequestInFlight = false + self.syncBatchInProgress = false + self.stalledSyncBatchCount = 0 + self.pendingIncomingMessages.removeAll() + self.isProcessingIncomingMessages = false + + // Desktop parity: request message synchronization after authentication. self.requestSynchronize() self.retryWaitingOutgoingMessagesAfterReconnect() - // Clear dedup set so we re-fetch user info (including online status) after reconnect. + // Clear dedup sets on reconnect so subscriptions can be re-established lazily. self.requestedUserInfoKeys.removeAll() - // Request fresh online status for all existing dialogs via PacketSearch. - self.refreshOnlineStatusForAllDialogs() + self.onlineSubscribedKeys.removeAll() } } @@ -468,12 +476,11 @@ final class SessionManager { requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash) } - if !fromMe && !wasKnownBefore { - var deliveryPacket = PacketDelivery() - deliveryPacket.toPublicKey = packet.fromPublicKey - deliveryPacket.messageId = packet.messageId - ProtocolManager.shared.sendPacket(deliveryPacket) - } + // Desktop parity: do NOT send PacketDelivery (0x08) back to server. + // The server auto-generates delivery confirmations when it forwards + // the message — the client never needs to acknowledge receipt explicitly. + // Sending 0x08 for every received message was causing a packet flood + // that triggered server RST disconnects. // Desktop parity: only mark as read if user is NOT idle AND app is in foreground. let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) @@ -528,8 +535,10 @@ final class SessionManager { /// Only sends when authenticated — does NOT queue to avoid flooding server on reconnect. func subscribeToOnlineStatus(publicKey: String) { guard !publicKey.isEmpty, - ProtocolManager.shared.connectionState == .authenticated + ProtocolManager.shared.connectionState == .authenticated, + !onlineSubscribedKeys.contains(publicKey) else { return } + onlineSubscribedKeys.insert(publicKey) var packet = PacketOnlineSubscribe() packet.publicKey = publicKey ProtocolManager.shared.sendPacket(packet) @@ -682,14 +691,19 @@ final class SessionManager { /// After handshake, request user info for all existing dialog opponents. /// This populates online status from search results (PacketSearch response includes `online` field). - private func refreshOnlineStatusForAllDialogs() { + private func refreshOnlineStatusForAllDialogs() async { let dialogs = DialogRepository.shared.dialogs let ownKey = currentPublicKey var count = 0 for (key, _) in dialogs { guard key != ownKey, !key.isEmpty else { continue } + guard ProtocolManager.shared.connectionState == .authenticated else { break } requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash) count += 1 + // Stagger sends to avoid server RST from packet flood + if count > 1 { + try? await Task.sleep(for: .milliseconds(100)) + } } Self.logger.info("Refreshing online status for \(count) dialogs") } diff --git a/Rosetta/DesignSystem/Components/AvatarView.swift b/Rosetta/DesignSystem/Components/AvatarView.swift index 6579bf1..828a12d 100644 --- a/Rosetta/DesignSystem/Components/AvatarView.swift +++ b/Rosetta/DesignSystem/Components/AvatarView.swift @@ -60,7 +60,7 @@ struct AvatarView: View { .overlay { Circle() .stroke( - Color.black, + RosettaColors.Adaptive.background, lineWidth: badgeBorderWidth ) } diff --git a/Rosetta/DesignSystem/Components/ButtonStyles.swift b/Rosetta/DesignSystem/Components/ButtonStyles.swift index d7e28ef..bb6193d 100644 --- a/Rosetta/DesignSystem/Components/ButtonStyles.swift +++ b/Rosetta/DesignSystem/Components/ButtonStyles.swift @@ -27,11 +27,7 @@ struct GlassBackButton: View { .glassEffect(.regular, in: .circle) } else { Circle() - .fill(Color.white.opacity(0.08)) - .overlay { - Circle() - .stroke(Color.white.opacity(0.12), lineWidth: 0.5) - } + .fill(.ultraThinMaterial) } } } @@ -67,20 +63,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..f229849 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -23,17 +23,7 @@ struct GlassCard: View { 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) - } + .fill(.ultraThinMaterial) } } } diff --git a/Rosetta/DesignSystem/Components/GlassModifier.swift b/Rosetta/DesignSystem/Components/GlassModifier.swift index 0a16bf4..1eb095a 100644 --- a/Rosetta/DesignSystem/Components/GlassModifier.swift +++ b/Rosetta/DesignSystem/Components/GlassModifier.swift @@ -1,13 +1,9 @@ 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 +// iOS 26+: native .glassEffect API +// iOS < 26: .ultraThinMaterial blur struct GlassModifier: ViewModifier { let cornerRadius: CGFloat @@ -24,20 +20,7 @@ struct GlassModifier: ViewModifier { } 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) + shape.fill(.ultraThinMaterial) } } } @@ -46,7 +29,7 @@ struct GlassModifier: ViewModifier { // MARK: - View Extension extension View { - /// 5-layer frosted glass background. + /// Glass background (native on iOS 26+, blur on older). func glass(cornerRadius: CGFloat = 24) -> some View { modifier(GlassModifier(cornerRadius: cornerRadius)) } @@ -61,20 +44,7 @@ extension View { } } 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) + Capsule().fill(.ultraThinMaterial) } } } diff --git a/Rosetta/DesignSystem/Components/GlassModifiers.swift b/Rosetta/DesignSystem/Components/GlassModifiers.swift index 0db332e..40808fe 100644 --- a/Rosetta/DesignSystem/Components/GlassModifiers.swift +++ b/Rosetta/DesignSystem/Components/GlassModifiers.swift @@ -2,14 +2,15 @@ import SwiftUI // MARK: - Glass Navigation Bar Modifier -/// Applies glassmorphism effect to the navigation bar on iOS 26+, falling back to ultra-thin material. +/// iOS 26+: native glassmorphism (no explicit background needed). +/// iOS < 26: solid adaptive background. struct GlassNavBarModifier: ViewModifier { func body(content: Content) -> some View { if #available(iOS 26, *) { content } else { content - .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar) } } } @@ -22,7 +23,8 @@ extension View { // MARK: - Glass Search Bar Modifier -/// Applies glassmorphism capsule effect on iOS 26+. +/// iOS 26+: glassmorphism capsule. +/// iOS < 26: no-op (standard search bar appearance). struct GlassSearchBarModifier: ViewModifier { func body(content: Content) -> some View { if #available(iOS 26, *) { 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/ConfirmSeedPhraseView.swift b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift index 5de8575..4265de6 100644 --- a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift @@ -128,7 +128,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() diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index d43f6db..cb22fc9 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -107,6 +107,8 @@ struct ChatDetailView: View { .toolbar(.hidden, for: .tabBar) .task { isViewActive = true + // Reset idle timer — user is actively viewing a chat. + SessionManager.shared.recordUserInteraction() // Request user info (non-mutating, won't trigger list rebuild) requestUserInfoIfNeeded() // Delay ALL dialog mutations to let navigation transition complete. @@ -321,6 +323,8 @@ private extension ChatDetailView { } .onChange(of: isInputFocused) { _, focused in guard focused else { return } + // User tapped the input — reset idle timer. + SessionManager.shared.recordUserInteraction() Task { @MainActor in try? await Task.sleep(nanoseconds: 80_000_000) scrollToBottom(proxy: proxy, animated: true) @@ -585,21 +589,14 @@ private extension ChatDetailView { .glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) } } else { - let border = strokeColor.opacity(max(0.28, strokeOpacity)) switch shape { case .capsule: - Capsule() - .fill(.ultraThinMaterial) - .overlay(Capsule().stroke(border, lineWidth: 0.8)) + Capsule().fill(.ultraThinMaterial) case .circle: - Circle() - .fill(.ultraThinMaterial) - .overlay(Circle().stroke(border, lineWidth: 0.8)) + Circle().fill(.ultraThinMaterial) case let .rounded(radius): - let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous) - rounded + RoundedRectangle(cornerRadius: radius, style: .continuous) .fill(.ultraThinMaterial) - .overlay(rounded.stroke(border, lineWidth: 0.8)) } } } @@ -703,6 +700,8 @@ private extension ChatDetailView { func sendCurrentMessage() { let message = trimmedMessage guard !message.isEmpty else { return } + // User is sending a message — reset idle timer. + SessionManager.shared.recordUserInteraction() messageText = "" sendError = nil diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift index df56522..cf7e341 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -31,6 +31,7 @@ final class ChatListViewModel: ObservableObject { setupSearchCallback() } + // MARK: - Computed (local dialog filtering) var filteredDialogs: [Dialog] { diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index 2bc2ec1..d4917c7 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -211,7 +211,7 @@ private extension ChatRowView { return Text(text) .font(.system(size: 15)) .tracking(-0.23) - .foregroundStyle(.black) + .foregroundStyle(.white) .padding(.horizontal, isSmall ? 0 : 4) .frame( minWidth: 20, diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index a3954e6..3df49ce 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -37,7 +37,7 @@ struct MainTabView: View { Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon) } .tag(RosettaTab.chats) - .badgeIfNeeded(chatUnreadBadge) + .badge(chatUnreadCount) SettingsView(onLogout: onLogout) .tabItem { @@ -86,8 +86,7 @@ struct MainTabView: View { dragFractionalIndex = nil } } - }, - badges: tabBadges + } ) .ignoresSafeArea(.keyboard) .transition(.move(edge: .bottom).combined(with: .opacity)) @@ -154,10 +153,16 @@ struct MainTabView: View { return [TabBadge(tab: .chats, text: chatUnreadBadge)] } - private var chatUnreadBadge: String? { - let unread = DialogRepository.shared.sortedDialogs + /// Int badge for iOS 26+ TabView — `.badge(0)` shows nothing, + /// and being non-conditional preserves ChatListView's structural identity. + private var chatUnreadCount: Int { + DialogRepository.shared.sortedDialogs .filter { !$0.isMuted } .reduce(0) { $0 + $1.unreadCount } + } + + private var chatUnreadBadge: String? { + let unread = chatUnreadCount if unread <= 0 { return nil } @@ -166,13 +171,12 @@ struct MainTabView: View { } private extension View { - @ViewBuilder + /// Non-conditional badge — preserves structural identity. + /// A @ViewBuilder `if let / else` creates two branches; switching + /// between them changes the child's structural identity, destroying + /// any @StateObject (including ChatListNavigationState.path). func badgeIfNeeded(_ value: String?) -> some View { - if let value { - badge(value) - } else { - self - } + badge(Text(value ?? "")) } } @@ -229,8 +233,7 @@ struct PlaceholderTabView: View { .foregroundStyle(RosettaColors.Adaptive.text) } } - .toolbarBackground(.ultraThinMaterial, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) + .applyGlassNavBar() } } } diff --git a/Rosetta/Features/Onboarding/OnboardingView.swift b/Rosetta/Features/Onboarding/OnboardingView.swift index 60a2d5f..c5756ca 100644 --- a/Rosetta/Features/Onboarding/OnboardingView.swift +++ b/Rosetta/Features/Onboarding/OnboardingView.swift @@ -11,7 +11,7 @@ struct OnboardingView: View { var body: some View { GeometryReader { geometry in ZStack { - RosettaColors.Dark.background.ignoresSafeArea() + RosettaColors.authBackground.ignoresSafeArea() // Pager fills the entire screen — swipe works everywhere OnboardingPager( @@ -80,7 +80,7 @@ private struct OnboardingPageSlide: View { Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(RosettaColors.Dark.background) + .background(RosettaColors.authBackground) .onAppear { isPlaying = true } .onDisappear { isPlaying = false } .accessibilityElement(children: .contain) diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index 6dda0f5..2237084 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -39,8 +39,8 @@ struct SettingsView: View { .foregroundStyle(RosettaColors.primaryBlue) } } - .toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) + .applyGlassNavBar() .task { viewModel.refresh() } .alert("Log Out", isPresented: $showLogoutConfirmation) { Button("Cancel", role: .cancel) {} @@ -216,7 +216,7 @@ struct SettingsView: View { .foregroundStyle(RosettaColors.Adaptive.text) .padding(.horizontal, 20) .padding(.vertical, 10) - .background(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E))) + .background(RosettaColors.Adaptive.backgroundSecondary) .clipShape(Capsule()) .padding(.top, 60) } diff --git a/Rosetta/Features/Splash/SplashView.swift b/Rosetta/Features/Splash/SplashView.swift deleted file mode 100644 index 64163be..0000000 --- a/Rosetta/Features/Splash/SplashView.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI - -struct SplashView: View { - let onSplashComplete: () -> Void - - @State private var logoScale: CGFloat = 0.3 - @State private var logoOpacity: Double = 0 - @State private var glowScale: CGFloat = 0.3 - @State private var glowPulsing = false - - var body: some View { - ZStack { - RosettaColors.Adaptive.background - .ignoresSafeArea() - - ZStack { - // Glow behind logo - Circle() - .fill(Color(hex: 0x54A9EB).opacity(0.2)) - .frame(width: 180, height: 180) - .scaleEffect(glowScale * (glowPulsing ? 1.1 : 1.0)) - .opacity(logoOpacity) - - // Logo - Image("RosettaIcon") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 150, height: 150) - .clipShape(Circle()) - .scaleEffect(logoScale) - .opacity(logoOpacity) - } - } - .task { - try? await Task.sleep(for: .milliseconds(100)) - - withAnimation(.easeOut(duration: 0.6)) { - logoOpacity = 1 - } - withAnimation(.spring(response: 0.6, dampingFraction: 0.6)) { - logoScale = 1 - glowScale = 1 - } - withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { - glowPulsing = true - } - - try? await Task.sleep(for: .seconds(2)) - onSplashComplete() - } - .accessibilityLabel("Rosetta") - } -} - -#Preview { - SplashView(onSplashComplete: {}) -} diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 5fb0593..717113f 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -3,7 +3,6 @@ import SwiftUI // MARK: - App State private enum AppState { - case splash case onboarding case auth case unlock @@ -31,34 +30,34 @@ struct RosettaApp: App { @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("isLoggedIn") private var isLoggedIn = false @AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false - @State private var appState: AppState = .splash + @State private var appState: AppState? var body: some Scene { WindowGroup { ZStack { - Color.black + RosettaColors.Dark.background .ignoresSafeArea() - rootView - .transition(.opacity.animation(.easeInOut(duration: 0.5))) + if let appState { + rootView(for: appState) + .transition(.opacity.animation(.easeInOut(duration: 0.5))) + } } .preferredColorScheme(.dark) + .onAppear { + if appState == nil { + appState = initialState() + } + } } } @MainActor static var _bodyCount = 0 @ViewBuilder - private var rootView: some View { + private func rootView(for state: AppState) -> some View { let _ = Self._bodyCount += 1 - let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(appState)") - switch appState { - case .splash: - SplashView { - withAnimation(.easeInOut(duration: 0.55)) { - determineNextState() - } - } - + let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(state)") + switch state { case .onboarding: OnboardingView { withAnimation(.easeInOut(duration: 0.55)) { @@ -110,14 +109,12 @@ struct RosettaApp: App { } } - private func determineNextState() { + private func initialState() -> AppState { if AccountManager.shared.hasAccount { - // Existing user — unlock with password - appState = .unlock + return .unlock } else { - // No account — always show onboarding first, then auth hasCompletedOnboarding = false - appState = .onboarding + return .onboarding } } }