Дизайн-система iOS < 26: чёрный фон, blur элементов, удаление SplashView

This commit is contained in:
2026-03-09 12:10:43 +05:00
parent 30e9e3664c
commit b1f71c43f0
17 changed files with 352 additions and 633 deletions

View File

@@ -106,8 +106,14 @@ final class DialogRepository {
if fromMe {
dialog.iHaveSent = true
} else {
// 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
schedulePersist()

View File

@@ -32,6 +32,7 @@ final class SessionManager {
private var pendingReadReceiptKeys: Set<String> = []
private var lastReadReceiptSentAt: [String: Int64] = [:]
private var requestedUserInfoKeys: Set<String> = []
private var onlineSubscribedKeys: Set<String> = []
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
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")
}

View File

@@ -60,7 +60,7 @@ struct AvatarView: View {
.overlay {
Circle()
.stroke(
Color.black,
RosettaColors.Adaptive.background,
lineWidth: badgeBorderWidth
)
}

View File

@@ -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
)
)
}
}
}

View File

@@ -23,17 +23,7 @@ struct GlassCard<Content: View>: 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)
}
}
}

View File

@@ -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. whiteclear 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)
}
}
}
@@ -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)
}
}
}

View File

@@ -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, *) {

View File

@@ -52,332 +52,232 @@ 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
// 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)
}
var body: some View {
interactiveTabBarContent
.padding(.horizontal, 25)
.padding(.top, 4)
}
private var interactiveTabBarContent: some View {
tabBarContent
.coordinateSpace(name: Self.tabBarSpace)
.onPreferenceChange(TabFramePreferenceKey.self) { frames in
tabFrames = frames
}
.contentShape(Rectangle())
.gesture(tabSelectionGesture)
.overlay(alignment: .topLeading) {
liftedLensOverlay
}
.onDisappear {
interactionState = nil
onSwipeStateChanged?(nil)
}
}
private var tabBarContent: some View {
HStack(spacing: 8) {
mainTabsPill
searchPill
}
}
private var visualSelectedTab: RosettaTab {
if let interactionState, interactionState.isLifted {
return interactionState.hoveredTab
}
return selectedTab
}
private var tabSelectionGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace))
.onChanged(handleGestureChanged)
.onEnded(handleGestureEnded)
}
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()
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 {
// Single pill with all tabs same structure as iOS 26 system TabView
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)
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]
)
}
)
.tabFramePreference(tab: tab, in: Self.tabBarSpace)
}
}
.padding(.horizontal, 4)
.padding(.top, 3)
.padding(.bottom, 3)
.frame(height: 62)
.padding(4)
.coordinateSpace(name: "tabBar")
.onPreferenceChange(TabWidthPreferenceKey.self) { tabWidths = $0 }
.onPreferenceChange(TabOriginPreferenceKey.self) { tabOrigins = $0 }
.background(alignment: .leading) {
selectionIndicator
}
.background {
mainPillGlass
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() } }
}
/// 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
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
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
)
).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)
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
}
.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)
// 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)
}
}
@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)
private var totalTabWidth: CGFloat {
tabWidths.values.reduce(0, +)
}
.compositingGroup()
}
}
// MARK: - Tab Item
// MARK: - Tab Content
private struct TabItemView: View {
let tab: RosettaTab
let isSelected: Bool
let isCoveredByLens: Bool
let badgeText: String?
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
var body: some View {
VStack(spacing: 1) {
return Button {
guard !isDragging else { return }
UIImpactFeedbackGenerator(style: .light).impactOccurred()
onTabSelected?(tab)
} label: {
VStack(spacing: 2) {
ZStack(alignment: .topTrailing) {
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
.font(.system(size: 22))
.foregroundStyle(tabColor)
.frame(height: 30)
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 ? 4 : 0)
.padding(.horizontal, badge.count > 2 ? 4 : 0)
.frame(minWidth: 18, minHeight: 18)
.background(Capsule().fill(RosettaColors.error))
.offset(x: 10, y: -4)
@@ -385,146 +285,64 @@ private struct TabItemView: View {
}
Text(tab.label)
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
.foregroundStyle(tabColor)
.font(.system(size: 10, weight: isEffectivelySelected ? .bold : .medium))
.foregroundStyle(tint)
}
.frame(maxWidth: .infinity)
.padding(14)
.opacity(isCoveredByLens ? 0.07 : 1)
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
.frame(minWidth: 66, maxWidth: .infinity)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.accessibilityLabel(tab.label)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityAddTraits(isEffectivelySelected ? .isSelected : [])
}
private var tabColor: Color {
isSelected
? RosettaColors.primaryBlue
: RosettaColors.adaptive(
light: Color(hex: 0x404040),
dark: Color(hex: 0x8E8E93)
// 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: - Search Pill
// MARK: - Shadow (iOS < 26 only)
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)
/// 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 {
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, badgeText.count > 2 ? 5 : 0)
.frame(minWidth: 20, minHeight: 20)
.background(Capsule().fill(RosettaColors.error))
.offset(x: 16, y: -9)
}
}
Text(tab.label)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.primaryBlue)
}
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>) -> 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)
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -31,6 +31,7 @@ final class ChatListViewModel: ObservableObject {
setupSearchCallback()
}
// MARK: - Computed (local dialog filtering)
var filteredDialogs: [Dialog] {

View File

@@ -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,

View File

@@ -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()
}
}
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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: {})
}

View File

@@ -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
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
}
}
}