Compare commits

...

2 Commits

Author SHA1 Message Date
8e27542c5b Выравнивание аватарок и онлайн-индикатора iOS с desktop (Mantine v8) 2026-03-08 17:10:02 +05:00
196765f038 Откат случайно включённых изменений дизайн-системы
Предыдущий коммит случайно включил изменения из рабочей
директории: упрощение GlassModifier, GlassModifiers,
RosettaTabBar, ButtonStyles, GlassCard и других файлов,
что сломало iOS 26 glass-эффекты и внешний вид tab bar.

Восстановлены оригинальные файлы из состояния до этих изменений.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 05:23:09 +05:00
37 changed files with 1198 additions and 615 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,8 @@
rosetta-android/ rosetta-android/
sprints/ sprints/
CLAUDE.md CLAUDE.md
.claude.local.md
desktop
# Xcode # Xcode
build/ build/
@@ -25,4 +27,3 @@ Package.resolved
.DS_Store .DS_Store
*.swp *.swp
*~ *~
.claude.local.md

View File

@@ -264,7 +264,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -280,7 +280,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.3; MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -301,7 +301,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -317,7 +317,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.3; MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES"> shouldAutocreateTestPlan = "YES">
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Release" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0" launchStyle = "0"

View File

@@ -1,13 +0,0 @@
{
"images" : [
{
"filename" : "back_5.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -69,7 +69,7 @@ struct Dialog: Identifiable, Codable, Equatable {
} }
var avatarColorIndex: Int { var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: opponentKey) RosettaColors.avatarColorIndex(for: opponentTitle, publicKey: opponentKey)
} }
var initials: String { var initials: String {

View File

@@ -72,25 +72,12 @@ final class DialogRepository {
} }
/// Creates or updates a dialog from an incoming message packet. /// Creates or updates a dialog from an incoming message packet.
/// func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) {
/// - Parameters:
/// - isNewMessage: `true` when the message was NOT already known locally.
/// Pass `false` for sync duplicates to avoid inflating `unreadCount`.
/// - isDialogActive: `true` when the user currently has this chat open.
/// Incoming messages in an active dialog do NOT increment `unreadCount`.
func updateFromMessage(
_ packet: PacketMessage,
myPublicKey: String,
decryptedText: String,
isNewMessage: Bool = true,
isDialogActive: Bool = false
) {
if currentAccount.isEmpty { if currentAccount.isEmpty {
currentAccount = myPublicKey currentAccount = myPublicKey
} }
let fromMe = packet.fromPublicKey == myPublicKey let fromMe = packet.fromPublicKey == myPublicKey
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let incomingTimestamp = normalizeTimestamp(packet.timestamp)
var dialog = dialogs[opponentKey] ?? Dialog( var dialog = dialogs[opponentKey] ?? Dialog(
id: opponentKey, id: opponentKey,
@@ -111,26 +98,22 @@ final class DialogRepository {
lastMessageDelivered: .waiting lastMessageDelivered: .waiting
) )
// Android parity: only overwrite last-message fields when the incoming
// message is at least as new as the current latest. During sync, older
// messages must not overwrite a newer last-message preview.
if incomingTimestamp >= dialog.lastMessageTimestamp {
dialog.lastMessage = decryptedText dialog.lastMessage = decryptedText
dialog.lastMessageTimestamp = incomingTimestamp dialog.lastMessageTimestamp = normalizeTimestamp(packet.timestamp)
dialog.lastMessageFromMe = fromMe dialog.lastMessageFromMe = fromMe
dialog.lastMessageDelivered = fromMe ? .waiting : .delivered dialog.lastMessageDelivered = fromMe ? .waiting : .delivered
}
if fromMe { if fromMe {
dialog.iHaveSent = true dialog.iHaveSent = true
} else if isNewMessage && !isDialogActive { } else {
// Only increment unread for genuinely new incoming messages
// when the user is NOT currently viewing this dialog.
dialog.unreadCount += 1 dialog.unreadCount += 1
} }
dialogs[opponentKey] = dialog dialogs[opponentKey] = dialog
schedulePersist() schedulePersist()
// Desktop parity: re-evaluate request status based on last N messages.
updateRequestStatus(opponentKey: opponentKey)
} }
func ensureDialog( func ensureDialog(
@@ -208,9 +191,9 @@ final class DialogRepository {
if !title.isEmpty { dialog.opponentTitle = title } if !title.isEmpty { dialog.opponentTitle = title }
if !username.isEmpty { dialog.opponentUsername = username } if !username.isEmpty { dialog.opponentUsername = username }
if verified > 0 { dialog.verified = max(dialog.verified, verified) } if verified > 0 { dialog.verified = max(dialog.verified, verified) }
// online: server sends inverted values (0 = online, 1 = offline), -1 = not provided // online: 0 = offline, 1 = online, -1 = not provided (don't update)
if online >= 0 { if online >= 0 {
dialog.isOnline = online == 0 dialog.isOnline = online > 0
if !dialog.isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) } if !dialog.isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) }
} }
dialogs[publicKey] = dialog dialogs[publicKey] = dialog
@@ -239,8 +222,52 @@ final class DialogRepository {
schedulePersist() schedulePersist()
} }
/// Desktop parity: check last N messages to determine if dialog should be a request.
/// If none of the last `dialogDropToRequestsMessageCount` messages are from me,
/// and the dialog is not a system account, mark as request (`iHaveSent = false`).
func updateRequestStatus(opponentKey: String) {
guard var dialog = dialogs[opponentKey] else { return }
// System accounts are never requests.
if SystemAccounts.isSystemAccount(opponentKey) {
if !dialog.iHaveSent {
dialog.iHaveSent = true
dialogs[opponentKey] = dialog
schedulePersist()
}
return
}
let messages = MessageRepository.shared.messages(for: opponentKey)
let recentMessages = messages.suffix(ProtocolConstants.dialogDropToRequestsMessageCount)
let hasMyMessage = recentMessages.contains { $0.fromPublicKey == currentAccount }
if dialog.iHaveSent != hasMyMessage {
dialog.iHaveSent = hasMyMessage
dialogs[opponentKey] = dialog
schedulePersist()
}
}
/// Desktop parity: remove dialog if it has no messages.
func removeDialogIfEmpty(opponentKey: String) {
let messages = MessageRepository.shared.messages(for: opponentKey)
if messages.isEmpty {
dialogs.removeValue(forKey: opponentKey)
schedulePersist()
}
}
func togglePin(opponentKey: String) { func togglePin(opponentKey: String) {
guard var dialog = dialogs[opponentKey] else { return } guard var dialog = dialogs[opponentKey] else { return }
if !dialog.isPinned {
// Desktop parity: max 3 pinned dialogs.
let pinnedCount = dialogs.values.filter(\.isPinned).count
if pinnedCount >= ProtocolConstants.maxPinnedDialogs {
return
}
}
dialog.isPinned.toggle() dialog.isPinned.toggle()
dialogs[opponentKey] = dialog dialogs[opponentKey] = dialog
schedulePersist() schedulePersist()

View File

@@ -5,8 +5,8 @@ import Combine
@MainActor @MainActor
final class MessageRepository: ObservableObject { final class MessageRepository: ObservableObject {
static let shared = MessageRepository() static let shared = MessageRepository()
// Android keeps full history in DB; keep a much larger in-memory cap to avoid visible message loss. // Desktop parity: MESSAGE_MAX_LOADED = 40 per dialog.
private let maxMessagesPerDialog = 5_000 private let maxMessagesPerDialog = ProtocolConstants.messageMaxCached
@Published private var messagesByDialog: [String: [ChatMessage]] = [:] @Published private var messagesByDialog: [String: [ChatMessage]] = [:]
@Published private var typingDialogs: Set<String> = [] @Published private var typingDialogs: Set<String> = []
@@ -135,7 +135,7 @@ final class MessageRepository: ObservableObject {
} }
} }
func updateDeliveryStatus(messageId: String, status: DeliveryStatus) { func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) {
guard let dialogKey = messageToDialog[messageId] else { return } guard let dialogKey = messageToDialog[messageId] else { return }
updateMessages(for: dialogKey) { messages in updateMessages(for: dialogKey) { messages in
guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return } guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return }
@@ -150,6 +150,10 @@ final class MessageRepository: ObservableObject {
return return
} }
messages[index].deliveryStatus = status messages[index].deliveryStatus = status
// Desktop parity: update timestamp on delivery ACK.
if let newTimestamp {
messages[index].timestamp = newTimestamp
}
} }
} }

View File

@@ -21,9 +21,8 @@ struct PacketOnlineState: Packet {
var list: [OnlineStateEntry] = [] var list: [OnlineStateEntry] = []
for _ in 0..<count { for _ in 0..<count {
let publicKey = stream.readString() let publicKey = stream.readString()
let rawOnline = stream.readBoolean() let isOnline = stream.readBoolean()
// Server sends inverted: true = offline, false = online negate it list.append(OnlineStateEntry(publicKey: publicKey, isOnline: isOnline))
list.append(OnlineStateEntry(publicKey: publicKey, isOnline: !rawOnline))
} }
entries = list entries = list
} }

View File

@@ -328,11 +328,13 @@ final class ProtocolManager: @unchecked Sendable {
private func startHeartbeat(interval: Int) { private func startHeartbeat(interval: Int) {
heartbeatTask?.cancel() heartbeatTask?.cancel()
// Match Android: intervalMs = (intervalSeconds * 1000L) / 2 // Desktop parity: heartbeat at half the server-specified interval.
let intervalNs = UInt64(interval) * 1_000_000_000 / 2 let intervalNs = UInt64(interval) * 1_000_000_000 / 2
heartbeatTask = Task { heartbeatTask = Task {
// Match Android: delay first, then send (no immediate first beat) // Send first heartbeat immediately
client.sendText("heartbeat")
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: intervalNs) try? await Task.sleep(nanoseconds: intervalNs)
guard !Task.isCancelled else { break } guard !Task.isCancelled else { break }

View File

@@ -6,10 +6,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "WebSocket") private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "WebSocket")
// Match Android: RECONNECT_INTERVAL = 10000L, MAX_RECONNECT_ATTEMPTS = 5
private static let reconnectInterval: UInt64 = 10_000_000_000 // 10 seconds (ns)
private static let maxReconnectAttempts = 5
private let url = URL(string: "wss://wss.rosetta.im")! private let url = URL(string: "wss://wss.rosetta.im")!
private var session: URLSession! private var session: URLSession!
private var webSocketTask: URLSessionWebSocketTask? private var webSocketTask: URLSessionWebSocketTask?
@@ -18,7 +14,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private var hasNotifiedConnected = false private var hasNotifiedConnected = false
private(set) var isConnected = false private(set) var isConnected = false
private var disconnectHandledForCurrentSocket = false private var disconnectHandledForCurrentSocket = false
private var reconnectAttempts = 0
var onConnected: (() -> Void)? var onConnected: (() -> Void)?
var onDisconnected: ((Error?) -> Void)? var onDisconnected: ((Error?) -> Void)?
@@ -28,9 +23,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
super.init() super.init()
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.waitsForConnectivity = true config.waitsForConnectivity = true
// Match Android OkHttp readTimeout(0) disable client-side idle timeout
config.timeoutIntervalForRequest = 604800 // 1 week
config.timeoutIntervalForResource = 604800
session = URLSession(configuration: config, delegate: self, delegateQueue: nil) session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
} }
@@ -96,7 +88,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
hasNotifiedConnected = true hasNotifiedConnected = true
isConnected = true isConnected = true
disconnectHandledForCurrentSocket = false disconnectHandledForCurrentSocket = false
reconnectAttempts = 0 // Match Android: reset on successful connection
reconnectTask?.cancel() reconnectTask?.cancel()
reconnectTask = nil reconnectTask = nil
onConnected?() onConnected?()
@@ -148,17 +139,10 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return } guard !isManuallyClosed else { return }
// Match Android: MAX_RECONNECT_ATTEMPTS = 5
guard reconnectAttempts < Self.maxReconnectAttempts else {
Self.logger.error("Max reconnect attempts reached (\(Self.maxReconnectAttempts))")
return
}
guard reconnectTask == nil else { return } guard reconnectTask == nil else { return }
reconnectAttempts += 1
reconnectTask = Task { [weak self] in reconnectTask = Task { [weak self] in
Self.logger.info("Reconnecting in 10 seconds (attempt \(self?.reconnectAttempts ?? 0)/\(Self.maxReconnectAttempts))...") Self.logger.info("Reconnecting in 5 seconds...")
try? await Task.sleep(nanoseconds: Self.reconnectInterval) try? await Task.sleep(nanoseconds: 5_000_000_000)
guard let self, !isManuallyClosed, !Task.isCancelled else { return } guard let self, !isManuallyClosed, !Task.isCancelled else { return }
self.reconnectTask = nil self.reconnectTask = nil
self.connect() self.connect()

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
import Observation import Observation
import os import os
import UIKit
/// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle. /// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle.
@Observable @Observable
@@ -34,14 +35,38 @@ final class SessionManager {
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] = [:]
private let maxOutgoingRetryAttempts = 3 private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
private let maxOutgoingWaitingLifetimeMs: Int64 = 80_000 private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
// MARK: - Idle Detection (Desktop parity)
/// Tracks the last user interaction timestamp for idle detection.
/// Desktop: messages marked unread if user idle > 20 seconds.
private var lastUserInteractionTime: Date = Date()
private var idleObserverToken: NSObjectProtocol?
/// Whether the user is considered idle (no interaction for `idleTimeoutForUnreadS`).
private var isUserIdle: Bool {
Date().timeIntervalSince(lastUserInteractionTime) > ProtocolConstants.idleTimeoutForUnreadS
}
/// Whether the app is in the foreground.
private var isAppInForeground: Bool {
UIApplication.shared.applicationState == .active
}
private var userInfoSearchHandlerToken: UUID? private var userInfoSearchHandlerToken: UUID?
private init() { private init() {
setupProtocolCallbacks() setupProtocolCallbacks()
setupUserInfoSearchHandler() setupUserInfoSearchHandler()
setupIdleDetection()
}
/// Desktop parity: track user interaction to implement idle detection.
/// Call this from any user-facing interaction (tap, scroll, keyboard).
func recordUserInteraction() {
lastUserInteractionTime = Date()
} }
// MARK: - Session Lifecycle // MARK: - Session Lifecycle
@@ -173,6 +198,7 @@ final class SessionManager {
pendingOutgoingRetryTasks.removeAll() pendingOutgoingRetryTasks.removeAll()
pendingOutgoingPackets.removeAll() pendingOutgoingPackets.removeAll()
pendingOutgoingAttempts.removeAll() pendingOutgoingAttempts.removeAll()
lastUserInteractionTime = Date()
isAuthenticated = false isAuthenticated = false
currentPublicKey = "" currentPublicKey = ""
displayName = "" displayName = ""
@@ -205,9 +231,12 @@ final class SessionManager {
status: .delivered status: .delivered
) )
} }
// Desktop parity: update both status AND timestamp on delivery ACK.
let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
MessageRepository.shared.updateDeliveryStatus( MessageRepository.shared.updateDeliveryStatus(
messageId: packet.messageId, messageId: packet.messageId,
status: .delivered status: .delivered,
newTimestamp: deliveryTimestamp
) )
self?.resolveOutgoingRetry(messageId: packet.messageId) self?.resolveOutgoingRetry(messageId: packet.messageId)
} }
@@ -410,7 +439,6 @@ final class SessionManager {
let fromMe = packet.fromPublicKey == myKey let fromMe = packet.fromPublicKey == myKey
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId) let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId)
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
let decryptedText = Self.decryptIncomingMessage( let decryptedText = Self.decryptIncomingMessage(
packet: packet, packet: packet,
@@ -426,11 +454,7 @@ final class SessionManager {
} }
DialogRepository.shared.updateFromMessage( DialogRepository.shared.updateFromMessage(
packet, packet, myPublicKey: myKey, decryptedText: text
myPublicKey: myKey,
decryptedText: text,
isNewMessage: !wasKnownBefore,
isDialogActive: dialogIsActive
) )
MessageRepository.shared.upsertFromMessagePacket( MessageRepository.shared.upsertFromMessagePacket(
packet, packet,
@@ -451,7 +475,11 @@ final class SessionManager {
ProtocolManager.shared.sendPacket(deliveryPacket) ProtocolManager.shared.sendPacket(deliveryPacket)
} }
if MessageRepository.shared.isDialogActive(opponentKey) { // Desktop parity: only mark as read if user is NOT idle AND app is in foreground.
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
let shouldMarkRead = dialogIsActive && !isUserIdle && isAppInForeground
if shouldMarkRead {
DialogRepository.shared.markAsRead(opponentKey: opponentKey) DialogRepository.shared.markAsRead(opponentKey: opponentKey)
MessageRepository.shared.markIncomingAsRead( MessageRepository.shared.markIncomingAsRead(
opponentKey: opponentKey, opponentKey: opponentKey,
@@ -844,4 +872,19 @@ final class SessionManager {
pendingOutgoingPackets.removeValue(forKey: messageId) pendingOutgoingPackets.removeValue(forKey: messageId)
pendingOutgoingAttempts.removeValue(forKey: messageId) pendingOutgoingAttempts.removeValue(forKey: messageId)
} }
// MARK: - Idle Detection Setup
private func setupIdleDetection() {
// Track app going to background/foreground to reset idle state.
idleObserverToken = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.lastUserInteractionTime = Date()
}
}
}
} }

View File

@@ -0,0 +1,52 @@
import Foundation
/// Centralized protocol constants matching the Desktop reference implementation.
enum ProtocolConstants {
/// Auto-reconnect delay in seconds.
static let reconnectIntervalS: TimeInterval = 5
/// Number of messages loaded per batch (scroll-to-top pagination).
static let maxMessagesLoad = 20
/// Maximum messages kept in memory per dialog.
static let messageMaxCached = 40
/// Outgoing message delivery timeout in seconds.
/// If a WAITING message is older than this, mark it as ERROR.
static let messageDeliveryTimeoutS: Int64 = 80
/// User idle timeout for marking incoming messages as unread (seconds).
/// Desktop: `TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD = 20`.
static let idleTimeoutForUnreadS: TimeInterval = 20
/// Maximum number of file attachments per message.
static let maxAttachmentsInMessage = 5
/// Number of recent messages checked to determine `is_request` status.
/// Desktop: `DIALOG_DROP_TO_REQUESTS_IF_NO_MESSAGES_FROM_ME_COUNT = 30`.
static let dialogDropToRequestsMessageCount = 30
/// Minimum time between avatar rendering in message list (seconds).
static let avatarNoRenderTimeDiffS = 300
/// Maximum upload file size in megabytes.
static let maxUploadFilesizeMB = 1024
/// Maximum number of pinned dialogs per account.
static let maxPinnedDialogs = 3
/// Outgoing message retry delay in seconds.
static let outgoingRetryDelayS: TimeInterval = 4
/// Maximum number of outgoing message retry attempts.
static let maxOutgoingRetryAttempts = 3
/// Read receipt throttle interval in milliseconds.
static let readReceiptThrottleMs: Int64 = 400
/// Typing indicator throttle interval in milliseconds.
static let typingThrottleMs: Int64 = 2_000
/// Typing indicator display timeout in seconds.
static let typingDisplayTimeoutS: TimeInterval = 3
}

View File

@@ -114,30 +114,38 @@ enum RosettaColors {
Color(hex: 0xF7DC6F), Color(hex: 0xF7DC6F),
] ]
// MARK: Avatar Palette (11 colors, matching rosetta-android dark theme) // MARK: Avatar Palette (Mantine v8 default, 11 colors)
// tint = shade-6 (used at 15% opacity for dark bg, 10% for light bg)
// text = shade-3 (dark mode text), shade-6 reused for light mode text
static let avatarColors: [(background: Color, text: Color)] = [ static let avatarColors: [(tint: Color, text: Color)] = [
(Color(hex: 0x2D3548), Color(hex: 0x7DD3FC)), // blue (Color(hex: 0x228be6), Color(hex: 0x74c0fc)), // blue
(Color(hex: 0x2D4248), Color(hex: 0x67E8F9)), // cyan (Color(hex: 0x15aabf), Color(hex: 0x66d9e8)), // cyan
(Color(hex: 0x39334C), Color(hex: 0xD8B4FE)), // grape (Color(hex: 0xbe4bdb), Color(hex: 0xe599f7)), // grape
(Color(hex: 0x2D3F32), Color(hex: 0x86EFAC)), // green (Color(hex: 0x40c057), Color(hex: 0x8ce99a)), // green
(Color(hex: 0x333448), Color(hex: 0xA5B4FC)), // indigo (Color(hex: 0x4c6ef5), Color(hex: 0x91a7ff)), // indigo
(Color(hex: 0x383F2D), Color(hex: 0xBEF264)), // lime (Color(hex: 0x82c91e), Color(hex: 0xc0eb75)), // lime
(Color(hex: 0x483529), Color(hex: 0xFDBA74)), // orange (Color(hex: 0xfd7e14), Color(hex: 0xffc078)), // orange
(Color(hex: 0x482D3D), Color(hex: 0xF9A8D4)), // pink (Color(hex: 0xe64980), Color(hex: 0xfaa2c1)), // pink
(Color(hex: 0x482D2D), Color(hex: 0xFCA5A5)), // red (Color(hex: 0xfa5252), Color(hex: 0xffa8a8)), // red
(Color(hex: 0x2D4340), Color(hex: 0x5EEAD4)), // teal (Color(hex: 0x12b886), Color(hex: 0x63e6be)), // teal
(Color(hex: 0x3A334C), Color(hex: 0xC4B5FD)), // violet (Color(hex: 0x7950f2), Color(hex: 0xb197fc)), // violet
] ]
static func avatarColorIndex(for key: String) -> Int { /// Desktop parity: color is determined by the display name, NOT the public key.
/// Server sends first 7 chars of publicKey as the default title; use that as fallback.
static func avatarColorIndex(for name: String, publicKey: String = "") -> Int {
let trimmed = name.trimmingCharacters(in: .whitespaces)
let input = trimmed.isEmpty ? String(publicKey.prefix(7)) : trimmed
var hash: Int32 = 0 var hash: Int32 = 0
for char in key.unicodeScalars { for char in input.unicodeScalars {
hash = hash &* 31 &+ Int32(truncatingIfNeeded: char.value) // Desktop: hash = (hash << 5) - hash + char hash * 31 + char
hash = (hash &<< 5) &- hash &+ Int32(truncatingIfNeeded: char.value)
// Desktop: hash |= 0 force 32-bit signed (Int32 handles this natively)
} }
let count = Int32(avatarColors.count) let count = Int32(avatarColors.count)
var index = hash % count var index = abs(hash) % count
if index < 0 { index += count } if index < 0 { index += count } // guard for Int32.min edge case
return Int(index) return Int(index)
} }

View File

@@ -9,21 +9,35 @@ struct AvatarView: View {
var isOnline: Bool = false var isOnline: Bool = false
var isSavedMessages: Bool = false var isSavedMessages: Bool = false
private var backgroundColor: Color { @Environment(\.colorScheme) private var colorScheme
RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].background
private var avatarPair: (tint: Color, text: Color) {
RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count]
} }
/// Mantine "light" variant: shade-3 in dark, shade-6 (tint) in light.
private var textColor: Color { private var textColor: Color {
RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].text colorScheme == .dark ? avatarPair.text : avatarPair.tint
} }
private var fontSize: CGFloat { size * 0.38 } private var fontSize: CGFloat { size * 0.38 }
private var badgeSize: CGFloat { size * 0.31 } /// Desktop parity: 12px dot on 50px avatar = 24%.
private var badgeSize: CGFloat { size * 0.24 }
/// Desktop parity: 2px border on 50px avatar = 4%.
private var badgeBorderWidth: CGFloat { max(size * 0.04, 1.5) }
/// Mantine dark body background (#1A1B1E).
private static let mantineDarkBody = Color(hex: 0x1A1B1E)
var body: some View { var body: some View {
ZStack { ZStack {
Circle() if isSavedMessages {
.fill(isSavedMessages ? RosettaColors.primaryBlue : backgroundColor) Circle().fill(RosettaColors.primaryBlue)
} else {
// Mantine "light" variant: opaque base + semi-transparent tint
Circle().fill(colorScheme == .dark ? Self.mantineDarkBody : .white)
Circle().fill(avatarPair.tint.opacity(colorScheme == .dark ? 0.15 : 0.10))
}
if isSavedMessages { if isSavedMessages {
Image(systemName: "bookmark.fill") Image(systemName: "bookmark.fill")
@@ -31,7 +45,7 @@ struct AvatarView: View {
.foregroundStyle(.white) .foregroundStyle(.white)
} else { } else {
Text(initials) Text(initials)
.font(.system(size: fontSize, weight: .semibold, design: .rounded)) .font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(textColor) .foregroundStyle(textColor)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.5) .minimumScaleFactor(0.5)
@@ -41,13 +55,18 @@ struct AvatarView: View {
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if isOnline { if isOnline {
Circle() Circle()
.fill(RosettaColors.figmaBlue) .fill(RosettaColors.primaryBlue)
.frame(width: size * 0.19, height: size * 0.19) .frame(width: badgeSize, height: badgeSize)
.overlay { .overlay {
Circle() Circle()
.stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.04) .stroke(
colorScheme == .dark
? Color(hex: 0x1A1B1E)
: Color.white,
lineWidth: badgeBorderWidth
)
} }
.offset(x: 0, y: -size * 0.06) .offset(x: 1, y: -1)
} }
} }
.accessibilityLabel(isSavedMessages ? "Saved Messages" : initials) .accessibilityLabel(isSavedMessages ? "Saved Messages" : initials)

View File

@@ -19,9 +19,20 @@ struct GlassBackButton: View {
.clipShape(Circle()) .clipShape(Circle())
} }
@ViewBuilder
private var glassCircle: some View { private var glassCircle: some View {
if #available(iOS 26, *) {
Circle() Circle()
.fill(Color.white.opacity(0.15)) .fill(Color.white.opacity(0.08))
.glassEffect(.regular, in: .circle)
} else {
Circle()
.fill(Color.white.opacity(0.08))
.overlay {
Circle()
.stroke(Color.white.opacity(0.12), lineWidth: 0.5)
}
}
} }
} }
@@ -35,9 +46,19 @@ struct RosettaPrimaryButtonStyle: ButtonStyle {
} }
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
Group {
if #available(iOS 26, *) {
configuration.label
.background {
Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.7 : 1.0))
}
.glassEffect(.regular, in: Capsule())
} else {
configuration.label configuration.label
.background { glassBackground(isPressed: configuration.isPressed) } .background { glassBackground(isPressed: configuration.isPressed) }
.clipShape(Capsule()) .clipShape(Capsule())
}
}
.scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0) .scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed) .animation(.easeOut(duration: 0.15), value: configuration.isPressed)
.allowsHitTesting(isEnabled) .allowsHitTesting(isEnabled)
@@ -46,6 +67,20 @@ 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
)
)
}
} }
} }

View File

@@ -16,6 +16,10 @@ struct GlassCard<Content: View>: View {
} }
var body: some View { var body: some View {
if #available(iOS 26, *) {
content()
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius))
} else {
content() content()
.background { .background {
RoundedRectangle(cornerRadius: cornerRadius) RoundedRectangle(cornerRadius: cornerRadius)
@@ -23,6 +27,14 @@ struct GlassCard<Content: View>: View {
light: Color.black.opacity(fillOpacity), light: Color.black.opacity(fillOpacity),
dark: Color.white.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)
}
}
} }
} }
} }

View File

@@ -1,24 +1,44 @@
import SwiftUI import SwiftUI
// MARK: - Glass Modifier // MARK: - Glass Modifier (5-layer glass that works on black)
// //
// Solid adaptive background no glass or material effects // Layer stack:
// 1. .ultraThinMaterial system blur
// 2. black.opacity(0.22) dark tint (depth on dark mode)
// 3. whiteclear 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
private var fillColor: Color {
RosettaColors.adaptive(
light: Color(hex: 0xF2F2F7),
dark: Color(hex: 0x1C1C1E)
)
}
func body(content: Content) -> some View { func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
if #available(iOS 26, *) {
content content
.background { .background {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) shape.fill(.clear)
.fill(fillColor) .glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
}
} else {
content
.background {
ZStack {
shape.fill(.ultraThinMaterial)
shape.fill(Color.black.opacity(0.22))
shape.fill(
LinearGradient(
colors: [Color.white.opacity(0.14), .clear],
startPoint: .top,
endPoint: .bottom
)
).blendMode(.screen)
shape.stroke(Color.white.opacity(0.10), lineWidth: 1)
shape.stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
}
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
}
} }
} }
} }
@@ -26,20 +46,36 @@ struct GlassModifier: ViewModifier {
// MARK: - View Extension // MARK: - View Extension
extension View { extension View {
/// Solid background with rounded corners. /// 5-layer frosted glass background.
func glass(cornerRadius: CGFloat = 24) -> some View { func glass(cornerRadius: CGFloat = 24) -> some View {
modifier(GlassModifier(cornerRadius: cornerRadius)) modifier(GlassModifier(cornerRadius: cornerRadius))
} }
/// Solid capsule background convenience for pill-shaped elements. /// Glass capsule convenience for pill-shaped elements.
@ViewBuilder
func glassCapsule() -> some View { func glassCapsule() -> some View {
if #available(iOS 26, *) {
background { background {
Capsule().fill(.clear)
.glassEffect(.regular, in: .capsule)
}
} else {
background {
ZStack {
Capsule().fill(.ultraThinMaterial)
Capsule().fill(Color.black.opacity(0.22))
Capsule().fill( Capsule().fill(
RosettaColors.adaptive( LinearGradient(
light: Color(hex: 0xF2F2F7), colors: [Color.white.opacity(0.14), .clear],
dark: Color(hex: 0x1C1C1E) 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

@@ -5,8 +5,12 @@ import SwiftUI
/// Applies glassmorphism effect to the navigation bar on iOS 26+, falling back to ultra-thin material. /// Applies glassmorphism effect to the navigation bar on iOS 26+, falling back to ultra-thin material.
struct GlassNavBarModifier: ViewModifier { struct GlassNavBarModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26, *) {
content content
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar) } else {
content
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
}
} }
} }
@@ -21,7 +25,12 @@ extension View {
/// Applies glassmorphism capsule effect on iOS 26+. /// Applies glassmorphism capsule effect on iOS 26+.
struct GlassSearchBarModifier: ViewModifier { struct GlassSearchBarModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26, *) {
content content
.glassEffect(.regular, in: .capsule)
} else {
content
}
} }
} }

View File

@@ -52,232 +52,332 @@ 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] = []
private let allTabs = RosettaTab.interactionOrder @State private var tabFrames: [RosettaTab: CGRect] = [:]
private let tabCount = RosettaTab.interactionOrder.count @State private var interactionState: TabPressInteraction?
// Drag state private static let tabBarSpace = "RosettaTabBarSpace"
@State private var isDragging = false private let lensLiftOffset: CGFloat = 12
@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 { var body: some View {
// Single pill with all tabs same structure as iOS 26 system TabView interactiveTabBarContent
HStack(spacing: 0) {
ForEach(Array(allTabs.enumerated()), id: \.element) { index, tab in
tabContent(tab: tab, index: index)
.background(
GeometryReader { geo in
Color.clear
.preference(
key: TabWidthPreferenceKey.self,
value: [index: geo.size.width]
)
.preference(
key: TabOriginPreferenceKey.self,
value: [index: geo.frame(in: .named("tabBar")).minX]
)
}
)
}
}
.padding(4)
.coordinateSpace(name: "tabBar")
.onPreferenceChange(TabWidthPreferenceKey.self) { tabWidths = $0 }
.onPreferenceChange(TabOriginPreferenceKey.self) { tabOrigins = $0 }
.background(alignment: .leading) {
selectionIndicator
}
.background {
if #available(iOS 26.0, *) {
// iOS 26+ liquid glass material for the capsule pill
Capsule()
.fill(.ultraThinMaterial)
} else {
// iOS < 26 solid dark capsule
Capsule()
.fill(TabBarColors.pillBackground)
.overlay(
Capsule()
.strokeBorder(TabBarColors.pillBorder, lineWidth: 0.5)
)
}
}
.contentShape(Capsule())
.gesture(dragGesture)
.modifier(TabBarShadowModifier())
.padding(.horizontal, 25) .padding(.horizontal, 25)
.padding(.top, 16) .padding(.top, 4)
.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 var interactiveTabBarContent: some View {
private func refreshBadges() { tabBarContent
let repo = DialogRepository.shared .coordinateSpace(name: Self.tabBarSpace)
let unread = repo.sortedDialogs .onPreferenceChange(TabFramePreferenceKey.self) { frames in
.filter { !$0.isMuted } tabFrames = frames
.reduce(0) { $0 + $1.unreadCount }
if unread <= 0 {
cachedBadgeText = nil
} else {
cachedBadgeText = unread > 999 ? "\(unread / 1000)K" : "\(unread)"
} }
.contentShape(Rectangle())
.gesture(tabSelectionGesture)
.overlay(alignment: .topLeading) {
liftedLensOverlay
} }
.onDisappear {
// MARK: - Selection Indicator interactionState = nil
@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 totalTabWidth: CGFloat { private var tabBarContent: some View {
tabWidths.values.reduce(0, +) HStack(spacing: 8) {
mainTabsPill
searchPill
}
} }
// MARK: - Tab Content private var visualSelectedTab: RosettaTab {
if let interactionState, interactionState.isLifted {
return interactionState.hoveredTab
}
return selectedTab
}
private func tabContent(tab: RosettaTab, index: Int) -> some View { private var tabSelectionGesture: some Gesture {
let frac = effectiveFractional DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace))
let distance = abs(frac - CGFloat(index)) .onChanged(handleGestureChanged)
let blend = (1 - distance).clamped(to: 0...1) .onEnded(handleGestureEnded)
let tint = tintColor(blend: blend) }
let isEffectivelySelected = blend > 0.5
let badge: String? = (tab == .chats) ? cachedBadgeText : nil
return Button { private func handleGestureChanged(_ value: DragGesture.Value) {
guard !isDragging else { return } 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() UIImpactFeedbackGenerator(style: .light).impactOccurred()
onTabSelected?(tab) interactionState = state
} label: { publishSwipeState(for: state)
VStack(spacing: 2) { return
ZStack(alignment: .topTrailing) { }
Image(systemName: isEffectivelySelected ? tab.selectedIcon : tab.icon)
.font(.system(size: 22, weight: .regular))
.foregroundStyle(tint)
.frame(height: 28)
if let badge { guard var state = interactionState else {
Text(badge) return
.font(.system(size: 10, weight: .bold)) }
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) .foregroundStyle(.white)
.padding(.horizontal, badge.count > 2 ? 4 : 0) .padding(.horizontal, badgeText.count > 2 ? 4 : 0)
.frame(minWidth: 18, minHeight: 18) .frame(minWidth: 18, minHeight: 18)
.background(Capsule().fill(RosettaColors.error)) .background(Capsule().fill(RosettaColors.error))
.offset(x: 10, y: -4) .offset(x: 10, y: -4)
@@ -285,64 +385,146 @@ struct RosettaTabBar: View {
} }
Text(tab.label) Text(tab.label)
.font(.system(size: 10, weight: isEffectivelySelected ? .bold : .medium)) .font(.system(size: 10, weight: isSelected ? .bold : .medium))
.foregroundStyle(tint) .foregroundStyle(tabColor)
} }
.frame(minWidth: 66, maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 6) .padding(14)
} .opacity(isCoveredByLens ? 0.07 : 1)
.buttonStyle(.plain) .animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
.accessibilityLabel(tab.label) .accessibilityLabel(tab.label)
.accessibilityAddTraits(isEffectivelySelected ? .isSelected : []) .accessibilityAddTraits(isSelected ? .isSelected : [])
} }
// MARK: - Color Interpolation private var tabColor: Color {
isSelected
/// Pre-computed RGBA to avoid creating UIColor on every drag frame. ? RosettaColors.primaryBlue
private static let unselectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = { : RosettaColors.adaptive(
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 light: Color(hex: 0x404040),
UIColor(TabBarColors.unselectedTint).getRed(&r, green: &g, blue: &b, alpha: &a) dark: Color(hex: 0x8E8E93)
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) // MARK: - Search Pill
/// Glass has built-in depth on iOS 26+, so shadow is only needed on older versions. private extension RosettaTabBar {
private struct TabBarShadowModifier: ViewModifier { var searchPill: some View {
func body(content: Content) -> some View { SearchPillView(
if #available(iOS 26.0, *) { isSelected: visualSelectedTab == .search,
content 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 { } else {
content VStack(spacing: 3) {
.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8) 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)
}
} }
} }
} }
// MARK: - Comparable Clamping // MARK: - Geometry Helpers
private extension Comparable { private struct TabPressInteraction {
func clamped(to range: ClosedRange<Self>) -> Self { let id: UUID
min(max(self, range.lowerBound), range.upperBound) let startTab: RosettaTab
let startCenterX: CGFloat
var currentCenterX: CGFloat
var hoveredTab: RosettaTab
var isLifted: Bool
}
private struct TabFramePreferenceKey: PreferenceKey {
static var defaultValue: [RosettaTab: CGRect] = [:]
static func reduce(value: inout [RosettaTab: CGRect], nextValue: () -> [RosettaTab: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
private extension View {
func tabFramePreference(tab: RosettaTab, in coordinateSpace: String) -> some View {
background {
GeometryReader { proxy in
Color.clear.preference(
key: TabFramePreferenceKey.self,
value: [tab: proxy.frame(in: .named(coordinateSpace))]
)
}
}
} }
} }
@@ -351,6 +533,12 @@ private extension Comparable {
#Preview { #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"),
]
)
} }
} }

View File

@@ -2,7 +2,7 @@ import SwiftUI
// MARK: - Auth Screen Enum // MARK: - Auth Screen Enum
enum AuthScreen: Hashable { enum AuthScreen: Equatable {
case welcome case welcome
case seedPhrase case seedPhrase
case confirmSeed case confirmSeed
@@ -16,12 +16,69 @@ struct AuthCoordinator: View {
let onAuthComplete: () -> Void let onAuthComplete: () -> Void
var onBackToUnlock: (() -> Void)? var onBackToUnlock: (() -> Void)?
@State private var path = NavigationPath() @State private var currentScreen: AuthScreen = .welcome
@State private var seedPhrase: [String] = [] @State private var seedPhrase: [String] = []
@State private var isImportMode = false @State private var isImportMode = false
@State private var navigationDirection: NavigationDirection = .forward
@State private var swipeOffset: CGFloat = 0
private var canSwipeBack: Bool {
currentScreen != .welcome
}
var body: some View { var body: some View {
NavigationStack(path: $path) { GeometryReader { geometry in
let screenWidth = geometry.size.width
ZStack {
RosettaColors.authBackground
.ignoresSafeArea()
// Previous screen peeks from behind during swipe
if canSwipeBack {
previousScreenView
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background { RosettaColors.authBackground.ignoresSafeArea() }
.offset(x: -screenWidth * 0.3 + swipeOffset * 0.3)
.overlay {
Color.black
.opacity(swipeOffset > 0
? 0.5 * (1.0 - swipeOffset / screenWidth)
: 1.0)
.ignoresSafeArea()
}
.allowsHitTesting(false)
}
// Current screen slides right during swipe
currentScreenView
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background { RosettaColors.authBackground.ignoresSafeArea() }
.transition(.move(edge: navigationDirection == .forward ? .trailing : .leading))
.id(currentScreen)
.offset(x: swipeOffset)
}
.overlay(alignment: .leading) {
if canSwipeBack {
Color.clear
.frame(width: 20)
.contentShape(Rectangle())
.padding(.top, 60)
.gesture(swipeBackGesture(screenWidth: screenWidth))
}
}
.preferredColorScheme(.dark)
}
}
}
// MARK: - Screen Views
private extension AuthCoordinator {
@ViewBuilder
var currentScreenView: some View {
switch currentScreen {
case .welcome:
WelcomeView( WelcomeView(
onGenerateSeed: { navigateTo(.seedPhrase) }, onGenerateSeed: { navigateTo(.seedPhrase) },
onImportSeed: { onImportSeed: {
@@ -30,40 +87,22 @@ struct AuthCoordinator: View {
}, },
onBack: onBackToUnlock onBack: onBackToUnlock
) )
.toolbar(.hidden, for: .navigationBar)
.navigationDestination(for: AuthScreen.self) { screen in
destinationView(for: screen)
.toolbar(.hidden, for: .navigationBar)
}
}
.preferredColorScheme(.dark)
}
}
// MARK: - Screen Views
private extension AuthCoordinator {
@ViewBuilder
func destinationView(for screen: AuthScreen) -> some View {
switch screen {
case .welcome:
EmptyView()
case .seedPhrase: case .seedPhrase:
SeedPhraseView( SeedPhraseView(
seedPhrase: $seedPhrase, seedPhrase: $seedPhrase,
onContinue: { navigateTo(.confirmSeed) }, onContinue: { navigateTo(.confirmSeed) },
onBack: { navigateBack() } onBack: { navigateBack(to: .welcome) }
) )
case .confirmSeed: case .confirmSeed:
ConfirmSeedPhraseView( ConfirmSeedPhraseView(
seedPhrase: $seedPhrase, seedPhrase: seedPhrase,
onConfirmed: { onConfirmed: {
isImportMode = false isImportMode = false
navigateTo(.setPassword) navigateTo(.setPassword)
}, },
onBack: { navigateBack() } onBack: { navigateBack(to: .seedPhrase) }
) )
case .importSeed: case .importSeed:
@@ -73,32 +112,112 @@ private extension AuthCoordinator {
isImportMode = true isImportMode = true
navigateTo(.setPassword) navigateTo(.setPassword)
}, },
onBack: { navigateBack() } onBack: { navigateBack(to: .welcome) }
) )
case .setPassword: case .setPassword:
SetPasswordView( SetPasswordView(
seedPhrase: $seedPhrase, seedPhrase: seedPhrase,
isImportMode: isImportMode, isImportMode: isImportMode,
onAccountCreated: onAuthComplete, onAccountCreated: onAuthComplete,
onBack: { navigateBack() } onBack: {
if isImportMode {
navigateBack(to: .importSeed)
} else {
navigateBack(to: .confirmSeed)
}
}
) )
} }
} }
@ViewBuilder
var previousScreenView: some View {
switch currentScreen {
case .welcome:
EmptyView()
case .seedPhrase:
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
case .confirmSeed:
SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
case .importSeed:
WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
case .setPassword:
if isImportMode {
ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
} else {
ConfirmSeedPhraseView(seedPhrase: seedPhrase, onConfirmed: {}, onBack: {})
} }
}
}
}
// MARK: - Transitions (kept minimal pure slide, no opacity, to avoid flash)
// MARK: - Navigation // MARK: - Navigation
private extension AuthCoordinator { private extension AuthCoordinator {
func navigateTo(_ screen: AuthScreen) { func navigateTo(_ screen: AuthScreen) {
path.append(screen) navigationDirection = .forward
withAnimation(.spring(response: 0.45, dampingFraction: 0.92)) {
currentScreen = screen
}
} }
func navigateBack() { func navigateBack(to screen: AuthScreen) {
guard !path.isEmpty else { return } navigationDirection = .backward
path.removeLast() withAnimation(.spring(response: 0.4, dampingFraction: 0.95)) {
currentScreen = screen
} }
} }
}
// MARK: - Swipe Back Gesture
private extension AuthCoordinator {
func swipeBackGesture(screenWidth: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 10)
.onChanged { value in
swipeOffset = max(value.translation.width, 0)
}
.onEnded { value in
let shouldGoBack = value.translation.width > 100
|| value.predictedEndTranslation.width > 200
if shouldGoBack {
withAnimation(.easeOut(duration: 0.25)) {
swipeOffset = screenWidth
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
swipeOffset = 0
navigationDirection = .backward
currentScreen = backDestination
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
swipeOffset = 0
}
}
}
}
var backDestination: AuthScreen {
switch currentScreen {
case .welcome: return .welcome
case .seedPhrase: return .welcome
case .confirmSeed: return .seedPhrase
case .importSeed: return .welcome
case .setPassword: return isImportMode ? .importSeed : .confirmSeed
}
}
}
// MARK: - Navigation Direction
private enum NavigationDirection {
case forward
case backward
}
#Preview { #Preview {
AuthCoordinator(onAuthComplete: {}) AuthCoordinator(onAuthComplete: {})

View File

@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct ConfirmSeedPhraseView: View { struct ConfirmSeedPhraseView: View {
@Binding var seedPhrase: [String] let seedPhrase: [String]
let onConfirmed: () -> Void let onConfirmed: () -> Void
let onBack: () -> Void let onBack: () -> Void
@@ -13,7 +13,6 @@ struct ConfirmSeedPhraseView: View {
private let confirmPositions = [1, 4, 8, 11] private let confirmPositions = [1, 4, 8, 11]
private var allCorrect: Bool { private var allCorrect: Bool {
guard seedPhrase.count >= 12 else { return false }
for (inputIndex, seedIndex) in confirmPositions.enumerated() { for (inputIndex, seedIndex) in confirmPositions.enumerated() {
let input = confirmationInputs[inputIndex].lowercased().trimmingCharacters(in: .whitespaces) let input = confirmationInputs[inputIndex].lowercased().trimmingCharacters(in: .whitespaces)
if input != seedPhrase[seedIndex].lowercased() { return false } if input != seedPhrase[seedIndex].lowercased() { return false }
@@ -25,7 +24,6 @@ struct ConfirmSeedPhraseView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
AuthNavigationBar(onBack: onBack) AuthNavigationBar(onBack: onBack)
if seedPhrase.count >= 12 {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 24) { VStack(spacing: 24) {
headerSection headerSection
@@ -45,12 +43,8 @@ struct ConfirmSeedPhraseView: View {
confirmButton confirmButton
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.bottom, 16) .padding(.bottom, 16)
} else {
Spacer()
} }
} }
.background { RosettaColors.authBackground.ignoresSafeArea() }
}
} }
// MARK: - Header // MARK: - Header
@@ -134,7 +128,7 @@ private extension ConfirmSeedPhraseView {
.foregroundStyle(RosettaColors.numberGray) .foregroundStyle(RosettaColors.numberGray)
.frame(width: 28, alignment: .trailing) .frame(width: 28, alignment: .trailing)
TextField("enter", text: $confirmationInputs[inputIndex]) TextField("enter word", 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()
@@ -287,8 +281,8 @@ private extension ConfirmSeedPhraseView {
#Preview { #Preview {
ConfirmSeedPhraseView( ConfirmSeedPhraseView(
seedPhrase: .constant(["abandon", "ability", "able", "about", "above", "absent", seedPhrase: ["abandon", "ability", "able", "about", "above", "absent",
"absorb", "abstract", "absurd", "abuse", "access", "accident"]), "absorb", "abstract", "absurd", "abuse", "access", "accident"],
onConfirmed: {}, onConfirmed: {},
onBack: {} onBack: {}
) )

View File

@@ -37,7 +37,6 @@ struct ImportSeedPhraseView: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.bottom, 16) .padding(.bottom, 16)
} }
.background { RosettaColors.authBackground.ignoresSafeArea() }
} }
} }

View File

@@ -37,7 +37,6 @@ struct SeedPhraseView: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.bottom, 16) .padding(.bottom, 16)
} }
.background { RosettaColors.authBackground.ignoresSafeArea() }
.onAppear(perform: generateSeedPhraseIfNeeded) .onAppear(perform: generateSeedPhraseIfNeeded)
} }
} }
@@ -118,12 +117,21 @@ private struct SeedCardStyle: ViewModifier {
let color: Color let color: Color
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12))
} else {
content content
.background { .background {
RoundedRectangle(cornerRadius: 14) RoundedRectangle(cornerRadius: 12)
.fill(color.opacity(0.15)) .fill(color.opacity(0.12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(color.opacity(0.18), lineWidth: 0.5)
}
}
.clipShape(RoundedRectangle(cornerRadius: 12))
} }
.clipShape(RoundedRectangle(cornerRadius: 14))
} }
} }
@@ -145,7 +153,7 @@ private extension SeedPhraseView {
.font(.system(size: 15, weight: .medium)) .font(.system(size: 15, weight: .medium))
} }
.foregroundStyle(showCopiedToast ? RosettaColors.success : RosettaColors.primaryBlue) .foregroundStyle(showCopiedToast ? RosettaColors.success : RosettaColors.primaryBlue)
.animation(.easeInOut(duration: 0.2), value: showCopiedToast) .contentTransition(.symbolEffect(.replace))
} }
.accessibilityLabel(showCopiedToast ? "Copied to clipboard" : "Copy seed phrase to clipboard") .accessibilityLabel(showCopiedToast ? "Copied to clipboard" : "Copy seed phrase to clipboard")
} }

View File

@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct SetPasswordView: View { struct SetPasswordView: View {
@Binding var seedPhrase: [String] let seedPhrase: [String]
let isImportMode: Bool let isImportMode: Bool
let onAccountCreated: () -> Void let onAccountCreated: () -> Void
let onBack: () -> Void let onBack: () -> Void
@@ -72,7 +72,6 @@ struct SetPasswordView: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.bottom, 16) .padding(.bottom, 16)
} }
.background { RosettaColors.authBackground.ignoresSafeArea() }
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
} }
} }
@@ -279,8 +278,8 @@ private extension SetPasswordView {
#Preview { #Preview {
SetPasswordView( SetPasswordView(
seedPhrase: .constant(["abandon", "ability", "able", "about", "above", "absent", seedPhrase: ["abandon", "ability", "able", "about", "above", "absent",
"absorb", "abstract", "absurd", "abuse", "access", "accident"]), "absorb", "abstract", "absurd", "abuse", "access", "accident"],
isImportMode: false, isImportMode: false,
onAccountCreated: {}, onAccountCreated: {},
onBack: {} onBack: {}

View File

@@ -30,7 +30,7 @@ struct UnlockView: View {
} }
private var avatarColorIndex: Int { private var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: publicKey) RosettaColors.avatarColorIndex(for: "", publicKey: publicKey)
} }
/// Short public key 7 characters like Android (e.g. "0325a4d"). /// Short public key 7 characters like Android (e.g. "0325a4d").

View File

@@ -49,7 +49,6 @@ struct WelcomeView: View {
.accessibilityLabel("Back") .accessibilityLabel("Back")
} }
} }
.background { RosettaColors.authBackground.ignoresSafeArea() }
.onAppear { .onAppear {
guard !isVisible else { return } guard !isVisible else { return }
withAnimation(.easeOut(duration: 0.5)) { isVisible = true } withAnimation(.easeOut(duration: 0.5)) { isVisible = true }

View File

@@ -207,7 +207,7 @@ private extension ChatDetailView {
} }
var avatarColorIndex: Int { var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: route.publicKey) RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
} }
var incomingBubbleFill: Color { var incomingBubbleFill: Color {
@@ -573,23 +573,36 @@ private extension ChatDetailView {
strokeOpacity: Double = 0.18, strokeOpacity: Double = 0.18,
strokeColor: Color = RosettaColors.Adaptive.border strokeColor: Color = RosettaColors.Adaptive.border
) -> some View { ) -> some View {
if #available(iOS 26.0, *) {
switch shape {
case .capsule:
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
case .circle:
Circle().fill(.clear).glassEffect(.regular, in: .circle)
case let .rounded(radius):
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
}
} else {
let border = strokeColor.opacity(max(0.28, strokeOpacity)) let border = strokeColor.opacity(max(0.28, strokeOpacity))
switch shape { switch shape {
case .capsule: case .capsule:
Capsule() Capsule()
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E))) .fill(.ultraThinMaterial)
.overlay(Capsule().stroke(border, lineWidth: 0.8)) .overlay(Capsule().stroke(border, lineWidth: 0.8))
case .circle: case .circle:
Circle() Circle()
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E))) .fill(.ultraThinMaterial)
.overlay(Circle().stroke(border, lineWidth: 0.8)) .overlay(Circle().stroke(border, lineWidth: 0.8))
case let .rounded(radius): case let .rounded(radius):
let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous) let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous)
rounded rounded
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E))) .fill(.ultraThinMaterial)
.overlay(rounded.stroke(border, lineWidth: 0.8)) .overlay(rounded.stroke(border, lineWidth: 0.8))
} }
} }
}
// MARK: - Actions / utils // MARK: - Actions / utils

View File

@@ -151,7 +151,7 @@ private extension ChatListSearchContent {
let initials = isSelf ? "S" : RosettaColors.initials( let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey name: user.title, publicKey: user.publicKey
) )
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button { return Button {
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username) onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
@@ -207,7 +207,7 @@ private extension ChatListSearchContent {
let initials = isSelf ? "S" : RosettaColors.initials( let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey name: user.title, publicKey: user.publicKey
) )
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button { return Button {
viewModel.addToRecent(user) viewModel.addToRecent(user)

View File

@@ -164,7 +164,7 @@ private struct ToolbarStoriesAvatar: View {
let initials = RosettaColors.initials( let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk name: SessionManager.shared.displayName, publicKey: pk
) )
let colorIdx = RosettaColors.avatarColorIndex(for: pk) let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk)
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) } ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
} }
} }

View File

@@ -103,8 +103,15 @@ final class ChatListViewModel: ObservableObject {
self.serverSearchResults = packet.users self.serverSearchResults = packet.users
self.isServerSearching = false self.isServerSearching = false
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)") Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
// Note: DialogRepository.updateUserInfo is handled by for user in packet.users {
// SessionManager.setupUserInfoSearchHandler avoid duplicate mutations. DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username,
verified: user.verified,
online: user.online
)
}
} }
} }
} }

View File

@@ -61,7 +61,7 @@ private extension SearchResultsSection {
func searchResultRow(_ user: SearchUser) -> some View { func searchResultRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == currentPublicKey let isSelf = user.publicKey == currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey) let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button { return Button {
onSelectUser(user) onSelectUser(user)

View File

@@ -249,7 +249,7 @@ private struct RecentSection: View {
let currentPK = SessionManager.shared.currentPublicKey let currentPK = SessionManager.shared.currentPublicKey
let isSelf = user.publicKey == currentPK let isSelf = user.publicKey == currentPK
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey) let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey) let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button { return Button {
navigationPath.append(ChatRoute(recent: user)) navigationPath.append(ChatRoute(recent: user))

View File

@@ -15,13 +15,48 @@ struct MainTabView: View {
@State private var dragFractionalIndex: CGFloat? @State private var dragFractionalIndex: CGFloat?
var body: some View { var body: some View {
let _ = Self._bodyCount += 1 Group {
let _ = print("🔴 MainTabView.body #\(Self._bodyCount) search=\(isChatSearchActive) chatDetail=\(isChatListDetailPresented) searchDetail=\(isSearchDetailPresented)") if #available(iOS 26.0, *) {
mainTabView systemTabView
} else {
legacyTabView
}
}
} }
@MainActor static var _bodyCount = 0
private var mainTabView: some View { // MARK: - iOS 26+ (native TabView with liquid glass tab bar)
@available(iOS 26.0, *)
private var systemTabView: some View {
TabView(selection: $selectedTab) {
ChatListView(
isSearchActive: $isChatSearchActive,
isDetailPresented: $isChatListDetailPresented
)
.tabItem {
Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon)
}
.tag(RosettaTab.chats)
.badgeIfNeeded(chatUnreadBadge)
SettingsView(onLogout: onLogout)
.tabItem {
Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon)
}
.tag(RosettaTab.settings)
SearchView(isDetailPresented: $isSearchDetailPresented)
.tabItem {
Label(RosettaTab.search.label, systemImage: RosettaTab.search.icon)
}
.tag(RosettaTab.search)
}
.tint(RosettaColors.primaryBlue)
}
// MARK: - iOS < 26 (custom RosettaTabBar with pager)
private var legacyTabView: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background RosettaColors.Adaptive.background
.ignoresSafeArea() .ignoresSafeArea()
@@ -35,7 +70,6 @@ struct MainTabView: View {
selectedTab: selectedTab, selectedTab: selectedTab,
onTabSelected: { tab in onTabSelected: { tab in
activatedTabs.insert(tab) activatedTabs.insert(tab)
// Activate adjacent tabs for smooth paging
for t in RosettaTab.interactionOrder { activatedTabs.insert(t) } for t in RosettaTab.interactionOrder { activatedTabs.insert(t) }
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) { withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
selectedTab = tab selectedTab = tab
@@ -43,7 +77,6 @@ struct MainTabView: View {
}, },
onSwipeStateChanged: { state in onSwipeStateChanged: { state in
if let state { if let state {
// Activate all main tabs during drag for smooth paging
for tab in RosettaTab.interactionOrder { for tab in RosettaTab.interactionOrder {
activatedTabs.insert(tab) activatedTabs.insert(tab)
} }
@@ -53,12 +86,18 @@ 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))
} }
} }
.onChange(of: isChatSearchActive) { _, isActive in
if isActive {
dragFractionalIndex = nil
}
}
} }
private var currentPageIndex: CGFloat { private var currentPageIndex: CGFloat {
@@ -70,8 +109,6 @@ struct MainTabView: View {
let width = max(1, availableSize.width) let width = max(1, availableSize.width)
let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count) let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count)
// Child views are in a separate HStack that does NOT read dragFractionalIndex,
// so they won't re-render during drag only the offset modifier updates.
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(RosettaTab.interactionOrder, id: \.self) { tab in ForEach(RosettaTab.interactionOrder, id: \.self) { tab in
tabView(for: tab) tabView(for: tab)
@@ -110,6 +147,33 @@ struct MainTabView: View {
isChatListDetailPresented || isSearchDetailPresented isChatListDetailPresented || isSearchDetailPresented
} }
private var tabBadges: [TabBadge] {
guard let chatUnreadBadge else {
return []
}
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
}
private var chatUnreadBadge: String? {
let unread = DialogRepository.shared.sortedDialogs
.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
if unread <= 0 {
return nil
}
return unread > 999 ? "\(unread / 1000)K" : "\(unread)"
}
}
private extension View {
@ViewBuilder
func badgeIfNeeded(_ value: String?) -> some View {
if let value {
badge(value)
} else {
self
}
}
} }
// MARK: - Pager Offset Modifier // MARK: - Pager Offset Modifier
@@ -120,11 +184,8 @@ private struct PagerOffsetModifier: ViewModifier {
let effectiveIndex: CGFloat let effectiveIndex: CGFloat
let pageWidth: CGFloat let pageWidth: CGFloat
let isDragging: Bool let isDragging: Bool
@MainActor static var _bodyCount = 0
func body(content: Content) -> some View { func body(content: Content) -> some View {
let _ = Self._bodyCount += 1
let _ = print("⬛ PagerOffset.body #\(Self._bodyCount) idx=\(effectiveIndex) w=\(pageWidth)")
content content
.offset(x: -effectiveIndex * pageWidth) .offset(x: -effectiveIndex * pageWidth)
.animation( .animation(
@@ -168,7 +229,7 @@ struct PlaceholderTabView: View {
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
} }
} }
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar) .toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
} }
} }

View File

@@ -9,19 +9,7 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
let count: Int let count: Int
let buildPage: (Int) -> Page let buildPage: (Int) -> Page
func makeCoordinator() -> OnboardingPagerCoordinator { func makeCoordinator() -> Coordinator { Coordinator(self) }
OnboardingPagerCoordinator(
currentIndex: currentIndex,
count: count,
currentIndexSetter: { [self] idx in self._currentIndex.wrappedValue = idx },
continuousProgressSetter: { [self] val in self._continuousProgress.wrappedValue = val },
buildControllers: { (0..<count).map { i in
let hc = UIHostingController(rootView: buildPage(i))
hc.view.backgroundColor = .clear
return hc
}}
)
}
func makeUIViewController(context: Context) -> UIPageViewController { func makeUIViewController(context: Context) -> UIPageViewController {
let vc = UIPageViewController( let vc = UIPageViewController(
@@ -48,41 +36,29 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
} }
func updateUIViewController(_ vc: UIPageViewController, context: Context) { func updateUIViewController(_ vc: UIPageViewController, context: Context) {
context.coordinator.count = count context.coordinator.parent = self
context.coordinator.currentIndex = currentIndex
context.coordinator.currentIndexSetter = { [self] idx in self._currentIndex.wrappedValue = idx }
context.coordinator.continuousProgressSetter = { [self] val in self._continuousProgress.wrappedValue = val }
}
} }
// MARK: - Coordinator (non-generic to avoid Swift compiler crash in Release optimiser) // MARK: - Coordinator
final class OnboardingPagerCoordinator: NSObject, final class Coordinator: NSObject,
UIPageViewControllerDataSource, UIPageViewControllerDataSource,
UIPageViewControllerDelegate, UIPageViewControllerDelegate,
UIScrollViewDelegate UIScrollViewDelegate
{ {
let controllers: [UIViewController] var parent: OnboardingPager
var count: Int let controllers: [UIHostingController<Page>]
var currentIndex: Int
var currentIndexSetter: (Int) -> Void
var continuousProgressSetter: (CGFloat) -> Void
var pageWidth: CGFloat = 0 var pageWidth: CGFloat = 0
private var pendingIndex: Int = 0 private var pendingIndex: Int = 0
init( init(_ parent: OnboardingPager) {
currentIndex: Int, self.parent = parent
count: Int, self.pendingIndex = parent.currentIndex
currentIndexSetter: @escaping (Int) -> Void, self.controllers = (0..<parent.count).map { i in
continuousProgressSetter: @escaping (CGFloat) -> Void, let hc = UIHostingController(rootView: parent.buildPage(i))
buildControllers: () -> [UIViewController] hc.view.backgroundColor = .clear
) { return hc
self.currentIndex = currentIndex }
self.count = count
self.currentIndexSetter = currentIndexSetter
self.continuousProgressSetter = continuousProgressSetter
self.pendingIndex = currentIndex
self.controllers = buildControllers()
} }
// MARK: DataSource // MARK: DataSource
@@ -100,7 +76,7 @@ final class OnboardingPagerCoordinator: NSObject,
viewControllerAfter vc: UIViewController viewControllerAfter vc: UIViewController
) -> UIViewController? { ) -> UIViewController? {
guard let idx = controllers.firstIndex(where: { $0 === vc }), guard let idx = controllers.firstIndex(where: { $0 === vc }),
idx < count - 1 else { return nil } idx < parent.count - 1 else { return nil }
return controllers[idx + 1] return controllers[idx + 1]
} }
@@ -125,8 +101,7 @@ final class OnboardingPagerCoordinator: NSObject,
guard completed, guard completed,
let current = pvc.viewControllers?.first, let current = pvc.viewControllers?.first,
let idx = controllers.firstIndex(where: { $0 === current }) else { return } let idx = controllers.firstIndex(where: { $0 === current }) else { return }
currentIndex = idx parent.currentIndex = idx
currentIndexSetter(idx)
} }
// MARK: ScrollView real-time progress // MARK: ScrollView real-time progress
@@ -134,9 +109,12 @@ final class OnboardingPagerCoordinator: NSObject,
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
let w = scrollView.frame.width let w = scrollView.frame.width
guard w > 0 else { return } guard w > 0 else { return }
// UIPageViewController's scroll view rests at x = width.
// Dragging left (next page) increases x; dragging right decreases.
let offsetFromCenter = scrollView.contentOffset.x - w let offsetFromCenter = scrollView.contentOffset.x - w
let fraction = offsetFromCenter / w let fraction = offsetFromCenter / w
let progress = CGFloat(currentIndex) + fraction let progress = CGFloat(parent.currentIndex) + fraction
continuousProgressSetter(max(0, min(CGFloat(count - 1), progress))) parent.continuousProgress = max(0, min(CGFloat(parent.count - 1), progress))
}
} }
} }

View File

@@ -27,7 +27,7 @@ final class SettingsViewModel: ObservableObject {
} }
var avatarColorIndex: Int { var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: publicKey) RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey)
} }
/// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`. /// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB