Дизайн-система iOS < 26: чёрный фон, blur элементов, удаление SplashView
This commit is contained in:
@@ -106,7 +106,13 @@ final class DialogRepository {
|
|||||||
if fromMe {
|
if fromMe {
|
||||||
dialog.iHaveSent = true
|
dialog.iHaveSent = true
|
||||||
} else {
|
} 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
|
dialogs[opponentKey] = dialog
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ final class SessionManager {
|
|||||||
private var pendingReadReceiptKeys: Set<String> = []
|
private var pendingReadReceiptKeys: Set<String> = []
|
||||||
private var lastReadReceiptSentAt: [String: Int64] = [:]
|
private var lastReadReceiptSentAt: [String: Int64] = [:]
|
||||||
private var requestedUserInfoKeys: Set<String> = []
|
private var requestedUserInfoKeys: Set<String> = []
|
||||||
|
private var onlineSubscribedKeys: Set<String> = []
|
||||||
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
|
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
|
||||||
private var pendingOutgoingPackets: [String: PacketMessage] = [:]
|
private var pendingOutgoingPackets: [String: PacketMessage] = [:]
|
||||||
private var pendingOutgoingAttempts: [String: Int] = [:]
|
private var pendingOutgoingAttempts: [String: Int] = [:]
|
||||||
@@ -337,14 +338,21 @@ final class SessionManager {
|
|||||||
Self.logger.debug("Skipping UserInfo — no profile data to send")
|
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.requestSynchronize()
|
||||||
self.retryWaitingOutgoingMessagesAfterReconnect()
|
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()
|
self.requestedUserInfoKeys.removeAll()
|
||||||
// Request fresh online status for all existing dialogs via PacketSearch.
|
self.onlineSubscribedKeys.removeAll()
|
||||||
self.refreshOnlineStatusForAllDialogs()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,12 +476,11 @@ final class SessionManager {
|
|||||||
requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash)
|
requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fromMe && !wasKnownBefore {
|
// Desktop parity: do NOT send PacketDelivery (0x08) back to server.
|
||||||
var deliveryPacket = PacketDelivery()
|
// The server auto-generates delivery confirmations when it forwards
|
||||||
deliveryPacket.toPublicKey = packet.fromPublicKey
|
// the message — the client never needs to acknowledge receipt explicitly.
|
||||||
deliveryPacket.messageId = packet.messageId
|
// Sending 0x08 for every received message was causing a packet flood
|
||||||
ProtocolManager.shared.sendPacket(deliveryPacket)
|
// that triggered server RST disconnects.
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop parity: only mark as read if user is NOT idle AND app is in foreground.
|
// Desktop parity: only mark as read if user is NOT idle AND app is in foreground.
|
||||||
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
|
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.
|
/// Only sends when authenticated — does NOT queue to avoid flooding server on reconnect.
|
||||||
func subscribeToOnlineStatus(publicKey: String) {
|
func subscribeToOnlineStatus(publicKey: String) {
|
||||||
guard !publicKey.isEmpty,
|
guard !publicKey.isEmpty,
|
||||||
ProtocolManager.shared.connectionState == .authenticated
|
ProtocolManager.shared.connectionState == .authenticated,
|
||||||
|
!onlineSubscribedKeys.contains(publicKey)
|
||||||
else { return }
|
else { return }
|
||||||
|
onlineSubscribedKeys.insert(publicKey)
|
||||||
var packet = PacketOnlineSubscribe()
|
var packet = PacketOnlineSubscribe()
|
||||||
packet.publicKey = publicKey
|
packet.publicKey = publicKey
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
@@ -682,14 +691,19 @@ final class SessionManager {
|
|||||||
|
|
||||||
/// After handshake, request user info for all existing dialog opponents.
|
/// After handshake, request user info for all existing dialog opponents.
|
||||||
/// This populates online status from search results (PacketSearch response includes `online` field).
|
/// 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 dialogs = DialogRepository.shared.dialogs
|
||||||
let ownKey = currentPublicKey
|
let ownKey = currentPublicKey
|
||||||
var count = 0
|
var count = 0
|
||||||
for (key, _) in dialogs {
|
for (key, _) in dialogs {
|
||||||
guard key != ownKey, !key.isEmpty else { continue }
|
guard key != ownKey, !key.isEmpty else { continue }
|
||||||
|
guard ProtocolManager.shared.connectionState == .authenticated else { break }
|
||||||
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
||||||
count += 1
|
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")
|
Self.logger.info("Refreshing online status for \(count) dialogs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ struct AvatarView: View {
|
|||||||
.overlay {
|
.overlay {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(
|
.stroke(
|
||||||
Color.black,
|
RosettaColors.Adaptive.background,
|
||||||
lineWidth: badgeBorderWidth
|
lineWidth: badgeBorderWidth
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ struct GlassBackButton: View {
|
|||||||
.glassEffect(.regular, in: .circle)
|
.glassEffect(.regular, in: .circle)
|
||||||
} else {
|
} else {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.white.opacity(0.08))
|
.fill(.ultraThinMaterial)
|
||||||
.overlay {
|
|
||||||
Circle()
|
|
||||||
.stroke(Color.white.opacity(0.12), lineWidth: 0.5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,20 +63,6 @@ struct RosettaPrimaryButtonStyle: ButtonStyle {
|
|||||||
private func glassBackground(isPressed: Bool) -> some View {
|
private func glassBackground(isPressed: Bool) -> some View {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(fillColor.opacity(isPressed ? 0.7 : 1.0))
|
.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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,17 +23,7 @@ struct GlassCard<Content: View>: View {
|
|||||||
content()
|
content()
|
||||||
.background {
|
.background {
|
||||||
RoundedRectangle(cornerRadius: cornerRadius)
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
.fill(RosettaColors.adaptive(
|
.fill(.ultraThinMaterial)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Glass Modifier (5-layer glass that works on black)
|
// MARK: - Glass Modifier
|
||||||
//
|
//
|
||||||
// Layer stack:
|
// iOS 26+: native .glassEffect API
|
||||||
// 1. .ultraThinMaterial — system blur
|
// iOS < 26: .ultraThinMaterial 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 {
|
struct GlassModifier: ViewModifier {
|
||||||
let cornerRadius: CGFloat
|
let cornerRadius: CGFloat
|
||||||
@@ -24,20 +20,7 @@ struct GlassModifier: ViewModifier {
|
|||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
.background {
|
.background {
|
||||||
ZStack {
|
shape.fill(.ultraThinMaterial)
|
||||||
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
|
// MARK: - View Extension
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
/// 5-layer frosted glass background.
|
/// Glass background (native on iOS 26+, blur on older).
|
||||||
func glass(cornerRadius: CGFloat = 24) -> some View {
|
func glass(cornerRadius: CGFloat = 24) -> some View {
|
||||||
modifier(GlassModifier(cornerRadius: cornerRadius))
|
modifier(GlassModifier(cornerRadius: cornerRadius))
|
||||||
}
|
}
|
||||||
@@ -61,20 +44,7 @@ extension View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
background {
|
background {
|
||||||
ZStack {
|
Capsule().fill(.ultraThinMaterial)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - Glass Navigation Bar Modifier
|
// 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 {
|
struct GlassNavBarModifier: ViewModifier {
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if #available(iOS 26, *) {
|
if #available(iOS 26, *) {
|
||||||
content
|
content
|
||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +23,8 @@ extension View {
|
|||||||
|
|
||||||
// MARK: - Glass Search Bar Modifier
|
// 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 {
|
struct GlassSearchBarModifier: ViewModifier {
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if #available(iOS 26, *) {
|
if #available(iOS 26, *) {
|
||||||
|
|||||||
@@ -52,479 +52,297 @@ struct TabBarSwipeState {
|
|||||||
let fractionalIndex: CGFloat
|
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
|
// MARK: - RosettaTabBar
|
||||||
|
|
||||||
struct RosettaTabBar: View {
|
struct RosettaTabBar: View {
|
||||||
let selectedTab: RosettaTab
|
let selectedTab: RosettaTab
|
||||||
var onTabSelected: ((RosettaTab) -> Void)?
|
var onTabSelected: ((RosettaTab) -> Void)?
|
||||||
var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)?
|
var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)?
|
||||||
var badges: [TabBadge] = []
|
|
||||||
|
|
||||||
@State private var tabFrames: [RosettaTab: CGRect] = [:]
|
private let allTabs = RosettaTab.interactionOrder
|
||||||
@State private var interactionState: TabPressInteraction?
|
private let tabCount = RosettaTab.interactionOrder.count
|
||||||
|
|
||||||
private static let tabBarSpace = "RosettaTabBarSpace"
|
// Drag state
|
||||||
private let lensLiftOffset: CGFloat = 12
|
@State private var isDragging = false
|
||||||
|
@State private var dragFractional: CGFloat = 0
|
||||||
|
@State private var dragStartIndex: CGFloat = 0
|
||||||
|
|
||||||
var body: some View {
|
// Measured tab geometry
|
||||||
interactiveTabBarContent
|
@State private var tabWidths: [Int: CGFloat] = [:]
|
||||||
.padding(.horizontal, 25)
|
@State private var tabOrigins: [Int: CGFloat] = [:]
|
||||||
.padding(.top, 4)
|
|
||||||
|
/// 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 {
|
var body: some View {
|
||||||
tabBarContent
|
// Single pill with all tabs — same structure as iOS 26 system TabView
|
||||||
.coordinateSpace(name: Self.tabBarSpace)
|
HStack(spacing: 0) {
|
||||||
.onPreferenceChange(TabFramePreferenceKey.self) { frames in
|
ForEach(Array(allTabs.enumerated()), id: \.element) { index, tab in
|
||||||
tabFrames = frames
|
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)
|
.padding(4)
|
||||||
.overlay(alignment: .topLeading) {
|
.coordinateSpace(name: "tabBar")
|
||||||
liftedLensOverlay
|
.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)
|
onSwipeStateChanged?(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tabBarContent: some View {
|
private var totalTabWidth: CGFloat {
|
||||||
HStack(spacing: 8) {
|
tabWidths.values.reduce(0, +)
|
||||||
mainTabsPill
|
|
||||||
searchPill
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var visualSelectedTab: RosettaTab {
|
// MARK: - Tab Content
|
||||||
if let interactionState, interactionState.isLifted {
|
|
||||||
return interactionState.hoveredTab
|
|
||||||
}
|
|
||||||
return selectedTab
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tabSelectionGesture: some Gesture {
|
private func tabContent(tab: RosettaTab, index: Int) -> some View {
|
||||||
DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace))
|
let frac = effectiveFractional
|
||||||
.onChanged(handleGestureChanged)
|
let distance = abs(frac - CGFloat(index))
|
||||||
.onEnded(handleGestureEnded)
|
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) {
|
return Button {
|
||||||
guard !tabFrames.isEmpty else {
|
guard !isDragging else { return }
|
||||||
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()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
interactionState = state
|
onTabSelected?(tab)
|
||||||
publishSwipeState(for: state)
|
} label: {
|
||||||
return
|
VStack(spacing: 2) {
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
ZStack(alignment: .topTrailing) {
|
||||||
Image(systemName: tab.selectedIcon)
|
Image(systemName: isEffectivelySelected ? tab.selectedIcon : tab.icon)
|
||||||
.font(.system(size: 30))
|
.font(.system(size: 22, weight: .regular))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(tint)
|
||||||
.frame(height: 36)
|
.frame(height: 28)
|
||||||
|
|
||||||
if let badgeText {
|
if let badge {
|
||||||
Text(badgeText)
|
Text(badge)
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.system(size: 10, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, badgeText.count > 2 ? 5 : 0)
|
.padding(.horizontal, badge.count > 2 ? 4 : 0)
|
||||||
.frame(minWidth: 20, minHeight: 20)
|
.frame(minWidth: 18, minHeight: 18)
|
||||||
.background(Capsule().fill(RosettaColors.error))
|
.background(Capsule().fill(RosettaColors.error))
|
||||||
.offset(x: 16, y: -9)
|
.offset(x: 10, y: -4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(tab.label)
|
Text(tab.label)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 10, weight: isEffectivelySelected ? .bold : .medium))
|
||||||
.foregroundStyle(RosettaColors.primaryBlue)
|
.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 {
|
private extension Comparable {
|
||||||
let id: UUID
|
func clamped(to range: ClosedRange<Self>) -> Self {
|
||||||
let startTab: RosettaTab
|
min(max(self, range.lowerBound), range.upperBound)
|
||||||
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))]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,12 +351,6 @@ private extension View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
|
RosettaTabBar(selectedTab: .chats)
|
||||||
RosettaTabBar(
|
|
||||||
selectedTab: .chats,
|
|
||||||
badges: [
|
|
||||||
TabBadge(tab: .chats, text: "7"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ private extension ConfirmSeedPhraseView {
|
|||||||
.foregroundStyle(RosettaColors.numberGray)
|
.foregroundStyle(RosettaColors.numberGray)
|
||||||
.frame(width: 28, alignment: .trailing)
|
.frame(width: 28, alignment: .trailing)
|
||||||
|
|
||||||
TextField("enter word", text: $confirmationInputs[inputIndex])
|
TextField("enter", text: $confirmationInputs[inputIndex])
|
||||||
.font(.system(size: 17, weight: .semibold, design: .monospaced))
|
.font(.system(size: 17, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ struct ChatDetailView: View {
|
|||||||
.toolbar(.hidden, for: .tabBar)
|
.toolbar(.hidden, for: .tabBar)
|
||||||
.task {
|
.task {
|
||||||
isViewActive = true
|
isViewActive = true
|
||||||
|
// Reset idle timer — user is actively viewing a chat.
|
||||||
|
SessionManager.shared.recordUserInteraction()
|
||||||
// Request user info (non-mutating, won't trigger list rebuild)
|
// Request user info (non-mutating, won't trigger list rebuild)
|
||||||
requestUserInfoIfNeeded()
|
requestUserInfoIfNeeded()
|
||||||
// Delay ALL dialog mutations to let navigation transition complete.
|
// Delay ALL dialog mutations to let navigation transition complete.
|
||||||
@@ -321,6 +323,8 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
.onChange(of: isInputFocused) { _, focused in
|
.onChange(of: isInputFocused) { _, focused in
|
||||||
guard focused else { return }
|
guard focused else { return }
|
||||||
|
// User tapped the input — reset idle timer.
|
||||||
|
SessionManager.shared.recordUserInteraction()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||||
scrollToBottom(proxy: proxy, animated: true)
|
scrollToBottom(proxy: proxy, animated: true)
|
||||||
@@ -585,21 +589,14 @@ private extension ChatDetailView {
|
|||||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let border = strokeColor.opacity(max(0.28, strokeOpacity))
|
|
||||||
switch shape {
|
switch shape {
|
||||||
case .capsule:
|
case .capsule:
|
||||||
Capsule()
|
Capsule().fill(.ultraThinMaterial)
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay(Capsule().stroke(border, lineWidth: 0.8))
|
|
||||||
case .circle:
|
case .circle:
|
||||||
Circle()
|
Circle().fill(.ultraThinMaterial)
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay(Circle().stroke(border, lineWidth: 0.8))
|
|
||||||
case let .rounded(radius):
|
case let .rounded(radius):
|
||||||
let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous)
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
rounded
|
|
||||||
.fill(.ultraThinMaterial)
|
.fill(.ultraThinMaterial)
|
||||||
.overlay(rounded.stroke(border, lineWidth: 0.8))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,6 +700,8 @@ private extension ChatDetailView {
|
|||||||
func sendCurrentMessage() {
|
func sendCurrentMessage() {
|
||||||
let message = trimmedMessage
|
let message = trimmedMessage
|
||||||
guard !message.isEmpty else { return }
|
guard !message.isEmpty else { return }
|
||||||
|
// User is sending a message — reset idle timer.
|
||||||
|
SessionManager.shared.recordUserInteraction()
|
||||||
messageText = ""
|
messageText = ""
|
||||||
sendError = nil
|
sendError = nil
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
setupSearchCallback()
|
setupSearchCallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Computed (local dialog filtering)
|
// MARK: - Computed (local dialog filtering)
|
||||||
|
|
||||||
var filteredDialogs: [Dialog] {
|
var filteredDialogs: [Dialog] {
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ private extension ChatRowView {
|
|||||||
return Text(text)
|
return Text(text)
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.tracking(-0.23)
|
.tracking(-0.23)
|
||||||
.foregroundStyle(.black)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, isSmall ? 0 : 4)
|
.padding(.horizontal, isSmall ? 0 : 4)
|
||||||
.frame(
|
.frame(
|
||||||
minWidth: 20,
|
minWidth: 20,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct MainTabView: View {
|
|||||||
Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon)
|
Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon)
|
||||||
}
|
}
|
||||||
.tag(RosettaTab.chats)
|
.tag(RosettaTab.chats)
|
||||||
.badgeIfNeeded(chatUnreadBadge)
|
.badge(chatUnreadCount)
|
||||||
|
|
||||||
SettingsView(onLogout: onLogout)
|
SettingsView(onLogout: onLogout)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
@@ -86,8 +86,7 @@ struct MainTabView: View {
|
|||||||
dragFractionalIndex = nil
|
dragFractionalIndex = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
badges: tabBadges
|
|
||||||
)
|
)
|
||||||
.ignoresSafeArea(.keyboard)
|
.ignoresSafeArea(.keyboard)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
@@ -154,10 +153,16 @@ struct MainTabView: View {
|
|||||||
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
|
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
|
||||||
}
|
}
|
||||||
|
|
||||||
private var chatUnreadBadge: String? {
|
/// Int badge for iOS 26+ TabView — `.badge(0)` shows nothing,
|
||||||
let unread = DialogRepository.shared.sortedDialogs
|
/// and being non-conditional preserves ChatListView's structural identity.
|
||||||
|
private var chatUnreadCount: Int {
|
||||||
|
DialogRepository.shared.sortedDialogs
|
||||||
.filter { !$0.isMuted }
|
.filter { !$0.isMuted }
|
||||||
.reduce(0) { $0 + $1.unreadCount }
|
.reduce(0) { $0 + $1.unreadCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chatUnreadBadge: String? {
|
||||||
|
let unread = chatUnreadCount
|
||||||
if unread <= 0 {
|
if unread <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -166,13 +171,12 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension 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 {
|
func badgeIfNeeded(_ value: String?) -> some View {
|
||||||
if let value {
|
badge(Text(value ?? ""))
|
||||||
badge(value)
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +233,7 @@ struct PlaceholderTabView: View {
|
|||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
.applyGlassNavBar()
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct OnboardingView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
RosettaColors.Dark.background.ignoresSafeArea()
|
RosettaColors.authBackground.ignoresSafeArea()
|
||||||
|
|
||||||
// Pager fills the entire screen — swipe works everywhere
|
// Pager fills the entire screen — swipe works everywhere
|
||||||
OnboardingPager(
|
OnboardingPager(
|
||||||
@@ -80,7 +80,7 @@ private struct OnboardingPageSlide: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(RosettaColors.Dark.background)
|
.background(RosettaColors.authBackground)
|
||||||
.onAppear { isPlaying = true }
|
.onAppear { isPlaying = true }
|
||||||
.onDisappear { isPlaying = false }
|
.onDisappear { isPlaying = false }
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(RosettaColors.primaryBlue)
|
.foregroundStyle(RosettaColors.primaryBlue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
|
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
|
.applyGlassNavBar()
|
||||||
.task { viewModel.refresh() }
|
.task { viewModel.refresh() }
|
||||||
.alert("Log Out", isPresented: $showLogoutConfirmation) {
|
.alert("Log Out", isPresented: $showLogoutConfirmation) {
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
@@ -216,7 +216,7 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
|
.background(RosettaColors.Adaptive.backgroundSecondary)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.padding(.top, 60)
|
.padding(.top, 60)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {})
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import SwiftUI
|
|||||||
// MARK: - App State
|
// MARK: - App State
|
||||||
|
|
||||||
private enum AppState {
|
private enum AppState {
|
||||||
case splash
|
|
||||||
case onboarding
|
case onboarding
|
||||||
case auth
|
case auth
|
||||||
case unlock
|
case unlock
|
||||||
@@ -31,34 +30,34 @@ struct RosettaApp: App {
|
|||||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||||
@AppStorage("isLoggedIn") private var isLoggedIn = false
|
@AppStorage("isLoggedIn") private var isLoggedIn = false
|
||||||
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
|
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
|
||||||
@State private var appState: AppState = .splash
|
@State private var appState: AppState?
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black
|
RosettaColors.Dark.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
rootView
|
if let appState {
|
||||||
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
|
rootView(for: appState)
|
||||||
|
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
.onAppear {
|
||||||
|
if appState == nil {
|
||||||
|
appState = initialState()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor static var _bodyCount = 0
|
@MainActor static var _bodyCount = 0
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var rootView: some View {
|
private func rootView(for state: AppState) -> some View {
|
||||||
let _ = Self._bodyCount += 1
|
let _ = Self._bodyCount += 1
|
||||||
let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(appState)")
|
let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(state)")
|
||||||
switch appState {
|
switch state {
|
||||||
case .splash:
|
|
||||||
SplashView {
|
|
||||||
withAnimation(.easeInOut(duration: 0.55)) {
|
|
||||||
determineNextState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .onboarding:
|
case .onboarding:
|
||||||
OnboardingView {
|
OnboardingView {
|
||||||
withAnimation(.easeInOut(duration: 0.55)) {
|
withAnimation(.easeInOut(duration: 0.55)) {
|
||||||
@@ -110,14 +109,12 @@ struct RosettaApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func determineNextState() {
|
private func initialState() -> AppState {
|
||||||
if AccountManager.shared.hasAccount {
|
if AccountManager.shared.hasAccount {
|
||||||
// Existing user — unlock with password
|
return .unlock
|
||||||
appState = .unlock
|
|
||||||
} else {
|
} else {
|
||||||
// No account — always show onboarding first, then auth
|
|
||||||
hasCompletedOnboarding = false
|
hasCompletedOnboarding = false
|
||||||
appState = .onboarding
|
return .onboarding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user