Compare commits
2 Commits
6bef51e235
...
685029b623
| Author | SHA1 | Date | |
|---|---|---|---|
| 685029b623 | |||
| e26d94b268 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ Package.resolved
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
.claude.local.md
|
||||||
|
|||||||
@@ -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 = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
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.0;
|
MARKETING_VERSION = 1.0.3;
|
||||||
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 = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
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.0;
|
MARKETING_VERSION = 1.0.3;
|
||||||
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 = "";
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
|
|||||||
13
Rosetta/Assets.xcassets/ChatBackground.imageset/Contents.json
vendored
Normal file
13
Rosetta/Assets.xcassets/ChatBackground.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_5.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Rosetta/Assets.xcassets/ChatBackground.imageset/back_5.png
vendored
Normal file
BIN
Rosetta/Assets.xcassets/ChatBackground.imageset/back_5.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -72,12 +72,25 @@ 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,
|
||||||
@@ -98,14 +111,21 @@ 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 = normalizeTimestamp(packet.timestamp)
|
dialog.lastMessageTimestamp = incomingTimestamp
|
||||||
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 {
|
} else if isNewMessage && !isDialogActive {
|
||||||
|
// Only increment unread for genuinely new incoming messages
|
||||||
|
// when the user is NOT currently viewing this dialog.
|
||||||
dialog.unreadCount += 1
|
dialog.unreadCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +208,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: 0 = offline, 1 = online, -1 = not provided (don't update)
|
// online: server sends inverted values (0 = online, 1 = offline), -1 = not provided
|
||||||
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
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ 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 isOnline = stream.readBoolean()
|
let rawOnline = stream.readBoolean()
|
||||||
list.append(OnlineStateEntry(publicKey: publicKey, isOnline: isOnline))
|
// Server sends inverted: true = offline, false = online — negate it
|
||||||
|
list.append(OnlineStateEntry(publicKey: publicKey, isOnline: !rawOnline))
|
||||||
}
|
}
|
||||||
entries = list
|
entries = list
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,14 +328,13 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
|
|
||||||
private func startHeartbeat(interval: Int) {
|
private func startHeartbeat(interval: Int) {
|
||||||
heartbeatTask?.cancel()
|
heartbeatTask?.cancel()
|
||||||
let intervalMs = UInt64(interval) * 1_000_000_000 / 3
|
// Match Android: intervalMs = (intervalSeconds * 1000L) / 2
|
||||||
|
let intervalNs = UInt64(interval) * 1_000_000_000 / 2
|
||||||
|
|
||||||
heartbeatTask = Task {
|
heartbeatTask = Task {
|
||||||
// Send first heartbeat immediately
|
// Match Android: delay first, then send (no immediate first beat)
|
||||||
client.sendText("heartbeat")
|
|
||||||
|
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: intervalMs)
|
try? await Task.sleep(nanoseconds: intervalNs)
|
||||||
guard !Task.isCancelled else { break }
|
guard !Task.isCancelled else { break }
|
||||||
client.sendText("heartbeat")
|
client.sendText("heartbeat")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ 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?
|
||||||
@@ -14,6 +18,7 @@ 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)?
|
||||||
@@ -23,6 +28,9 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +96,7 @@ 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?()
|
||||||
@@ -139,10 +148,17 @@ 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 5 seconds...")
|
Self.logger.info("Reconnecting in 10 seconds (attempt \(self?.reconnectAttempts ?? 0)/\(Self.maxReconnectAttempts))...")
|
||||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
try? await Task.sleep(nanoseconds: Self.reconnectInterval)
|
||||||
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()
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ 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,
|
||||||
@@ -425,7 +426,11 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DialogRepository.shared.updateFromMessage(
|
DialogRepository.shared.updateFromMessage(
|
||||||
packet, myPublicKey: myKey, decryptedText: text
|
packet,
|
||||||
|
myPublicKey: myKey,
|
||||||
|
decryptedText: text,
|
||||||
|
isNewMessage: !wasKnownBefore,
|
||||||
|
isDialogActive: dialogIsActive
|
||||||
)
|
)
|
||||||
MessageRepository.shared.upsertFromMessagePacket(
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
packet,
|
packet,
|
||||||
|
|||||||
@@ -38,16 +38,16 @@ struct AvatarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(alignment: .bottomLeading) {
|
.overlay(alignment: .bottomTrailing) {
|
||||||
if isOnline {
|
if isOnline {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color(hex: 0x4CD964))
|
.fill(RosettaColors.figmaBlue)
|
||||||
.frame(width: badgeSize, height: badgeSize)
|
.frame(width: size * 0.19, height: size * 0.19)
|
||||||
.overlay {
|
.overlay {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.05)
|
.stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.04)
|
||||||
}
|
}
|
||||||
.offset(x: -1, y: 1)
|
.offset(x: 0, y: -size * 0.06)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityLabel(isSavedMessages ? "Saved Messages" : initials)
|
.accessibilityLabel(isSavedMessages ? "Saved Messages" : initials)
|
||||||
|
|||||||
@@ -19,20 +19,9 @@ 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.08))
|
.fill(Color.white.opacity(0.15))
|
||||||
.glassEffect(.regular, in: .circle)
|
|
||||||
} else {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.white.opacity(0.08))
|
|
||||||
.overlay {
|
|
||||||
Circle()
|
|
||||||
.stroke(Color.white.opacity(0.12), lineWidth: 0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,19 +35,9 @@ 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)
|
||||||
@@ -67,20 +46,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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ 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)
|
||||||
@@ -27,14 +23,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,24 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Glass Modifier (5-layer glass that works on black)
|
// MARK: - Glass Modifier
|
||||||
//
|
//
|
||||||
// Layer stack:
|
// Solid adaptive background — no glass or material effects
|
||||||
// 1. .ultraThinMaterial — system blur
|
|
||||||
// 2. black.opacity(0.22) — dark tint (depth on dark mode)
|
|
||||||
// 3. white→clear gradient — highlight / light refraction, blendMode(.screen)
|
|
||||||
// 4. double stroke — outer weak + inner stronger = glass edge
|
|
||||||
// 5. shadow — depth
|
|
||||||
|
|
||||||
struct GlassModifier: ViewModifier {
|
struct GlassModifier: ViewModifier {
|
||||||
let cornerRadius: CGFloat
|
let cornerRadius: CGFloat
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
private var fillColor: Color {
|
||||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
RosettaColors.adaptive(
|
||||||
|
light: Color(hex: 0xF2F2F7),
|
||||||
if #available(iOS 26, *) {
|
dark: Color(hex: 0x1C1C1E)
|
||||||
content
|
|
||||||
.background {
|
|
||||||
shape.fill(.clear)
|
|
||||||
.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.background {
|
|
||||||
ZStack {
|
|
||||||
shape.fill(.ultraThinMaterial)
|
|
||||||
shape.fill(Color.black.opacity(0.22))
|
|
||||||
shape.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.white.opacity(0.14), .clear],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
)
|
||||||
).blendMode(.screen)
|
|
||||||
shape.stroke(Color.white.opacity(0.10), lineWidth: 1)
|
|
||||||
shape.stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
|
||||||
}
|
|
||||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
.fill(fillColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,36 +26,20 @@ struct GlassModifier: ViewModifier {
|
|||||||
// MARK: - View Extension
|
// MARK: - View Extension
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
/// 5-layer frosted glass background.
|
/// Solid background with rounded corners.
|
||||||
func glass(cornerRadius: CGFloat = 24) -> some View {
|
func glass(cornerRadius: CGFloat = 24) -> some View {
|
||||||
modifier(GlassModifier(cornerRadius: cornerRadius))
|
modifier(GlassModifier(cornerRadius: cornerRadius))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Glass capsule — convenience for pill-shaped elements.
|
/// Solid capsule background — 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(
|
||||||
LinearGradient(
|
RosettaColors.adaptive(
|
||||||
colors: [Color.white.opacity(0.14), .clear],
|
light: Color(hex: 0xF2F2F7),
|
||||||
startPoint: .top,
|
dark: Color(hex: 0x1C1C1E)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ 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
|
||||||
} else {
|
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
|
||||||
content
|
|
||||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,12 +21,7 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,332 +52,232 @@ 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
|
||||||
|
|
||||||
|
// 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 {
|
||||||
interactiveTabBarContent
|
// Single pill with all tabs — same structure as iOS 26 system TabView
|
||||||
.padding(.horizontal, 25)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var interactiveTabBarContent: some View {
|
|
||||||
tabBarContent
|
|
||||||
.coordinateSpace(name: Self.tabBarSpace)
|
|
||||||
.onPreferenceChange(TabFramePreferenceKey.self) { frames in
|
|
||||||
tabFrames = frames
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.gesture(tabSelectionGesture)
|
|
||||||
.overlay(alignment: .topLeading) {
|
|
||||||
liftedLensOverlay
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
interactionState = nil
|
|
||||||
onSwipeStateChanged?(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tabBarContent: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
mainTabsPill
|
|
||||||
searchPill
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var visualSelectedTab: RosettaTab {
|
|
||||||
if let interactionState, interactionState.isLifted {
|
|
||||||
return interactionState.hoveredTab
|
|
||||||
}
|
|
||||||
return selectedTab
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tabSelectionGesture: some Gesture {
|
|
||||||
DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace))
|
|
||||||
.onChanged(handleGestureChanged)
|
|
||||||
.onEnded(handleGestureEnded)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleGestureChanged(_ value: DragGesture.Value) {
|
|
||||||
guard !tabFrames.isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if interactionState == nil {
|
|
||||||
guard let startTab = tabAtStart(location: value.startLocation),
|
|
||||||
let startFrame = tabFrames[startTab]
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = TabPressInteraction(
|
|
||||||
id: UUID(),
|
|
||||||
startTab: startTab,
|
|
||||||
startCenterX: startFrame.midX,
|
|
||||||
currentCenterX: startFrame.midX,
|
|
||||||
hoveredTab: startTab,
|
|
||||||
isLifted: true
|
|
||||||
)
|
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
||||||
interactionState = state
|
|
||||||
publishSwipeState(for: state)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard var state = interactionState else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.currentCenterX = clampedCenterX(state.startCenterX + value.translation.width)
|
|
||||||
|
|
||||||
if let nearest = nearestTab(toX: state.currentCenterX), nearest != state.hoveredTab {
|
|
||||||
state.hoveredTab = nearest
|
|
||||||
if state.isLifted {
|
|
||||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interactionState = state
|
|
||||||
publishSwipeState(for: state)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleGestureEnded(_ value: DragGesture.Value) {
|
|
||||||
guard let state = interactionState else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetTab = nearestTab(toX: value.location.x) ?? state.hoveredTab
|
|
||||||
|
|
||||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.72)) {
|
|
||||||
interactionState = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
onSwipeStateChanged?(nil)
|
|
||||||
onTabSelected?(targetTab)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func publishSwipeState(for state: TabPressInteraction) {
|
|
||||||
guard state.isLifted,
|
|
||||||
let fractionalIndex = fractionalIndex(for: state.currentCenterX)
|
|
||||||
else {
|
|
||||||
onSwipeStateChanged?(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onSwipeStateChanged?(
|
|
||||||
TabBarSwipeState(
|
|
||||||
fromTab: state.startTab,
|
|
||||||
hoveredTab: state.hoveredTab,
|
|
||||||
fractionalIndex: fractionalIndex
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tabAtStart(location: CGPoint) -> RosettaTab? {
|
|
||||||
guard let nearest = nearestTab(toX: location.x),
|
|
||||||
let frame = tabFrames[nearest]
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return frame.insetBy(dx: -18, dy: -18).contains(location) ? nearest : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func nearestTab(toX x: CGFloat) -> RosettaTab? {
|
|
||||||
tabFrames.min { lhs, rhs in
|
|
||||||
abs(lhs.value.midX - x) < abs(rhs.value.midX - x)
|
|
||||||
}?.key
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clampedCenterX(_ value: CGFloat) -> CGFloat {
|
|
||||||
let centers = tabFrames.values.map(\.midX)
|
|
||||||
guard let minX = centers.min(), let maxX = centers.max() else {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return min(max(value, minX), maxX)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fractionalIndex(for centerX: CGFloat) -> CGFloat? {
|
|
||||||
let centers = RosettaTab.interactionOrder.compactMap { tab -> CGFloat? in
|
|
||||||
tabFrames[tab]?.midX
|
|
||||||
}
|
|
||||||
|
|
||||||
guard centers.count == RosettaTab.interactionOrder.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if centerX <= centers[0] {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if centerX >= centers[centers.count - 1] {
|
|
||||||
return CGFloat(centers.count - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for index in 0 ..< centers.count - 1 {
|
|
||||||
let left = centers[index]
|
|
||||||
let right = centers[index + 1]
|
|
||||||
guard centerX >= left, centerX <= right else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let progress = (centerX - left) / max(1, right - left)
|
|
||||||
return CGFloat(index) + progress
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func badgeText(for tab: RosettaTab) -> String? {
|
|
||||||
badges.first(where: { $0.tab == tab })?.text
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isCoveredByLens(_ tab: RosettaTab) -> Bool {
|
|
||||||
interactionState?.isLifted == true && interactionState?.hoveredTab == tab
|
|
||||||
}
|
|
||||||
|
|
||||||
private func lensDiameter(for tab: RosettaTab) -> CGFloat {
|
|
||||||
switch tab {
|
|
||||||
case .search:
|
|
||||||
return 88
|
|
||||||
default:
|
|
||||||
return 104
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Main Tabs Pill
|
|
||||||
|
|
||||||
private extension RosettaTabBar {
|
|
||||||
var mainTabsPill: some View {
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in
|
ForEach(Array(allTabs.enumerated()), id: \.element) { index, tab in
|
||||||
TabItemView(
|
tabContent(tab: tab, index: index)
|
||||||
tab: tab,
|
.background(
|
||||||
isSelected: tab == visualSelectedTab,
|
GeometryReader { geo in
|
||||||
isCoveredByLens: isCoveredByLens(tab),
|
Color.clear
|
||||||
badgeText: badgeText(for: tab)
|
.preference(
|
||||||
|
key: TabWidthPreferenceKey.self,
|
||||||
|
value: [index: geo.size.width]
|
||||||
|
)
|
||||||
|
.preference(
|
||||||
|
key: TabOriginPreferenceKey.self,
|
||||||
|
value: [index: geo.frame(in: .named("tabBar")).minX]
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.tabFramePreference(tab: tab, in: Self.tabBarSpace)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(4)
|
||||||
.padding(.top, 3)
|
.coordinateSpace(name: "tabBar")
|
||||||
.padding(.bottom, 3)
|
.onPreferenceChange(TabWidthPreferenceKey.self) { tabWidths = $0 }
|
||||||
.frame(height: 62)
|
.onPreferenceChange(TabOriginPreferenceKey.self) { tabOrigins = $0 }
|
||||||
|
.background(alignment: .leading) {
|
||||||
|
selectionIndicator
|
||||||
|
}
|
||||||
.background {
|
.background {
|
||||||
mainPillGlass
|
if #available(iOS 26.0, *) {
|
||||||
|
// iOS 26+ — liquid glass material for the capsule pill
|
||||||
|
Capsule()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
} else {
|
||||||
|
// iOS < 26 — solid dark capsule
|
||||||
|
Capsule()
|
||||||
|
.fill(TabBarColors.pillBackground)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(TabBarColors.pillBorder, lineWidth: 0.5)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.contentShape(Capsule())
|
||||||
|
.gesture(dragGesture)
|
||||||
|
.modifier(TabBarShadowModifier())
|
||||||
|
.padding(.horizontal, 25)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.onAppear { Task { @MainActor in refreshBadges() } }
|
||||||
|
.onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads DialogRepository outside the body's observation scope.
|
||||||
|
private func refreshBadges() {
|
||||||
|
let repo = DialogRepository.shared
|
||||||
|
let unread = repo.sortedDialogs
|
||||||
|
.filter { !$0.isMuted }
|
||||||
|
.reduce(0) { $0 + $1.unreadCount }
|
||||||
|
if unread <= 0 {
|
||||||
|
cachedBadgeText = nil
|
||||||
|
} else {
|
||||||
|
cachedBadgeText = unread > 999 ? "\(unread / 1000)K" : "\(unread)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection Indicator
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var mainPillGlass: some View {
|
private var selectionIndicator: some View {
|
||||||
ZStack {
|
let frac = effectiveFractional
|
||||||
Capsule().fill(.ultraThinMaterial)
|
let nearestIdx = Int(frac.rounded()).clamped(to: 0...(tabCount - 1))
|
||||||
Capsule().fill(Color.black.opacity(0.34))
|
let width = tabWidths[nearestIdx] ?? 80
|
||||||
Capsule().fill(
|
let xOffset = interpolatedOrigin(for: frac)
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.white.opacity(0.08), .clear],
|
Group {
|
||||||
startPoint: .top,
|
if #available(iOS 26.0, *) {
|
||||||
endPoint: .bottom
|
Capsule().fill(.thinMaterial)
|
||||||
|
.frame(width: width)
|
||||||
|
.offset(x: xOffset)
|
||||||
|
} else {
|
||||||
|
Capsule().fill(TabBarColors.selectionBackground)
|
||||||
|
.frame(width: width - 4)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.offset(x: xOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(
|
||||||
|
isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82),
|
||||||
|
value: frac
|
||||||
)
|
)
|
||||||
).blendMode(.screen)
|
|
||||||
Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1)
|
|
||||||
Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5)
|
|
||||||
}
|
|
||||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func interpolatedOrigin(for fractional: CGFloat) -> CGFloat {
|
||||||
var liftedLensOverlay: some View {
|
let lower = Int(fractional).clamped(to: 0...(tabCount - 1))
|
||||||
if let state = interactionState,
|
let upper = (lower + 1).clamped(to: 0...(tabCount - 1))
|
||||||
state.isLifted,
|
let t = fractional - CGFloat(lower)
|
||||||
let hoveredFrame = tabFrames[state.hoveredTab]
|
let lowerX = tabOrigins[lower] ?? 0
|
||||||
{
|
let upperX = tabOrigins[upper] ?? lowerX
|
||||||
let diameter = lensDiameter(for: state.hoveredTab)
|
return lowerX + (upperX - lowerX) * t
|
||||||
|
|
||||||
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)
|
// MARK: - Drag Gesture
|
||||||
.shadow(color: .black.opacity(0.42), radius: 24, y: 15)
|
|
||||||
.shadow(color: Color.cyan.opacity(0.10), radius: 20, y: 1)
|
private var dragGesture: some Gesture {
|
||||||
.allowsHitTesting(false)
|
DragGesture(minimumDistance: 8)
|
||||||
.transition(.scale(scale: 0.86).combined(with: .opacity))
|
.onChanged { value in
|
||||||
.animation(.spring(response: 0.34, dampingFraction: 0.74), value: state.isLifted)
|
if !isDragging {
|
||||||
.zIndex(20)
|
isDragging = true
|
||||||
|
dragStartIndex = CGFloat(selectedTab.interactionIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
let avgTabWidth = totalTabWidth / CGFloat(tabCount)
|
||||||
|
guard avgTabWidth > 0 else { return }
|
||||||
|
let delta = value.translation.width / avgTabWidth
|
||||||
|
let newFrac = (dragStartIndex - delta)
|
||||||
|
.clamped(to: 0...CGFloat(tabCount - 1))
|
||||||
|
|
||||||
|
dragFractional = newFrac
|
||||||
|
|
||||||
|
let nearestIdx = Int(newFrac.rounded()).clamped(to: 0...(tabCount - 1))
|
||||||
|
onSwipeStateChanged?(TabBarSwipeState(
|
||||||
|
fromTab: selectedTab,
|
||||||
|
hoveredTab: allTabs[nearestIdx],
|
||||||
|
fractionalIndex: newFrac
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
let avgTabWidth = totalTabWidth / CGFloat(tabCount)
|
||||||
|
let velocity = avgTabWidth > 0 ? value.predictedEndTranslation.width / avgTabWidth : 0
|
||||||
|
let projected = dragFractional - velocity * 0.15
|
||||||
|
let snappedIdx = Int(projected.rounded()).clamped(to: 0...(tabCount - 1))
|
||||||
|
let targetTab = allTabs[snappedIdx]
|
||||||
|
|
||||||
|
isDragging = false
|
||||||
|
dragFractional = CGFloat(snappedIdx)
|
||||||
|
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
onTabSelected?(targetTab)
|
||||||
|
onSwipeStateChanged?(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private var totalTabWidth: CGFloat {
|
||||||
var lensBubble: some View {
|
tabWidths.values.reduce(0, +)
|
||||||
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
|
// MARK: - Tab Content
|
||||||
|
|
||||||
private struct TabItemView: View {
|
private func tabContent(tab: RosettaTab, index: Int) -> some View {
|
||||||
let tab: RosettaTab
|
let frac = effectiveFractional
|
||||||
let isSelected: Bool
|
let distance = abs(frac - CGFloat(index))
|
||||||
let isCoveredByLens: Bool
|
let blend = (1 - distance).clamped(to: 0...1)
|
||||||
let badgeText: String?
|
let tint = tintColor(blend: blend)
|
||||||
|
let isEffectivelySelected = blend > 0.5
|
||||||
|
let badge: String? = (tab == .chats) ? cachedBadgeText : nil
|
||||||
|
|
||||||
var body: some View {
|
return Button {
|
||||||
VStack(spacing: 1) {
|
guard !isDragging else { return }
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
onTabSelected?(tab)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 2) {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
|
Image(systemName: isEffectivelySelected ? tab.selectedIcon : tab.icon)
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 22, weight: .regular))
|
||||||
.foregroundStyle(tabColor)
|
.foregroundStyle(tint)
|
||||||
.frame(height: 30)
|
.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 ? 4 : 0)
|
.padding(.horizontal, badge.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)
|
||||||
@@ -385,146 +285,64 @@ private struct TabItemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(tab.label)
|
Text(tab.label)
|
||||||
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
|
.font(.system(size: 10, weight: isEffectivelySelected ? .bold : .medium))
|
||||||
.foregroundStyle(tabColor)
|
.foregroundStyle(tint)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(minWidth: 66, maxWidth: .infinity)
|
||||||
.padding(14)
|
.padding(.vertical, 6)
|
||||||
.opacity(isCoveredByLens ? 0.07 : 1)
|
}
|
||||||
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(tab.label)
|
.accessibilityLabel(tab.label)
|
||||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
.accessibilityAddTraits(isEffectivelySelected ? .isSelected : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tabColor: Color {
|
// MARK: - Color Interpolation
|
||||||
isSelected
|
|
||||||
? RosettaColors.primaryBlue
|
/// Pre-computed RGBA to avoid creating UIColor on every drag frame.
|
||||||
: RosettaColors.adaptive(
|
private static let unselectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = {
|
||||||
light: Color(hex: 0x404040),
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
dark: Color(hex: 0x8E8E93)
|
UIColor(TabBarColors.unselectedTint).getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||||
|
return (r, g, b, a)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let selectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = {
|
||||||
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
UIColor(TabBarColors.selectedTint).getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||||
|
return (r, g, b, a)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func tintColor(blend: CGFloat) -> Color {
|
||||||
|
let t = blend.clamped(to: 0...1)
|
||||||
|
let (fr, fg, fb, fa) = Self.unselectedRGBA
|
||||||
|
let (tr, tg, tb, ta) = Self.selectedRGBA
|
||||||
|
return Color(
|
||||||
|
red: fr + (tr - fr) * t,
|
||||||
|
green: fg + (tg - fg) * t,
|
||||||
|
blue: fb + (tb - fb) * t,
|
||||||
|
opacity: fa + (ta - fa) * t
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search Pill
|
// MARK: - Shadow (iOS < 26 only)
|
||||||
|
|
||||||
private extension RosettaTabBar {
|
/// Glass has built-in depth on iOS 26+, so shadow is only needed on older versions.
|
||||||
var searchPill: some View {
|
private struct TabBarShadowModifier: ViewModifier {
|
||||||
SearchPillView(
|
func body(content: Content) -> some View {
|
||||||
isSelected: visualSelectedTab == .search,
|
if #available(iOS 26.0, *) {
|
||||||
isCoveredByLens: isCoveredByLens(.search)
|
content
|
||||||
)
|
|
||||||
.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 {
|
||||||
VStack(spacing: 3) {
|
content
|
||||||
ZStack(alignment: .topTrailing) {
|
.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8)
|
||||||
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: - 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"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - Auth Screen Enum
|
// MARK: - Auth Screen Enum
|
||||||
|
|
||||||
enum AuthScreen: Equatable {
|
enum AuthScreen: Hashable {
|
||||||
case welcome
|
case welcome
|
||||||
case seedPhrase
|
case seedPhrase
|
||||||
case confirmSeed
|
case confirmSeed
|
||||||
@@ -16,69 +16,12 @@ struct AuthCoordinator: View {
|
|||||||
let onAuthComplete: () -> Void
|
let onAuthComplete: () -> Void
|
||||||
var onBackToUnlock: (() -> Void)?
|
var onBackToUnlock: (() -> Void)?
|
||||||
|
|
||||||
@State private var currentScreen: AuthScreen = .welcome
|
@State private var path = NavigationPath()
|
||||||
@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 {
|
||||||
GeometryReader { geometry in
|
NavigationStack(path: $path) {
|
||||||
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: {
|
||||||
@@ -87,22 +30,40 @@ private extension AuthCoordinator {
|
|||||||
},
|
},
|
||||||
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(to: .welcome) }
|
onBack: { navigateBack() }
|
||||||
)
|
)
|
||||||
|
|
||||||
case .confirmSeed:
|
case .confirmSeed:
|
||||||
ConfirmSeedPhraseView(
|
ConfirmSeedPhraseView(
|
||||||
seedPhrase: seedPhrase,
|
seedPhrase: $seedPhrase,
|
||||||
onConfirmed: {
|
onConfirmed: {
|
||||||
isImportMode = false
|
isImportMode = false
|
||||||
navigateTo(.setPassword)
|
navigateTo(.setPassword)
|
||||||
},
|
},
|
||||||
onBack: { navigateBack(to: .seedPhrase) }
|
onBack: { navigateBack() }
|
||||||
)
|
)
|
||||||
|
|
||||||
case .importSeed:
|
case .importSeed:
|
||||||
@@ -112,111 +73,31 @@ private extension AuthCoordinator {
|
|||||||
isImportMode = true
|
isImportMode = true
|
||||||
navigateTo(.setPassword)
|
navigateTo(.setPassword)
|
||||||
},
|
},
|
||||||
onBack: { navigateBack(to: .welcome) }
|
onBack: { navigateBack() }
|
||||||
)
|
)
|
||||||
|
|
||||||
case .setPassword:
|
case .setPassword:
|
||||||
SetPasswordView(
|
SetPasswordView(
|
||||||
seedPhrase: seedPhrase,
|
seedPhrase: $seedPhrase,
|
||||||
isImportMode: isImportMode,
|
isImportMode: isImportMode,
|
||||||
onAccountCreated: onAuthComplete,
|
onAccountCreated: onAuthComplete,
|
||||||
onBack: {
|
onBack: { navigateBack() }
|
||||||
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) {
|
||||||
navigationDirection = .forward
|
path.append(screen)
|
||||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.92)) {
|
|
||||||
currentScreen = screen
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func navigateBack(to screen: AuthScreen) {
|
func navigateBack() {
|
||||||
navigationDirection = .backward
|
guard !path.isEmpty else { return }
|
||||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.95)) {
|
path.removeLast()
|
||||||
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 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ConfirmSeedPhraseView: View {
|
struct ConfirmSeedPhraseView: View {
|
||||||
let seedPhrase: [String]
|
@Binding var seedPhrase: [String]
|
||||||
let onConfirmed: () -> Void
|
let onConfirmed: () -> Void
|
||||||
let onBack: () -> Void
|
let onBack: () -> Void
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ 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 }
|
||||||
@@ -24,6 +25,7 @@ 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
|
||||||
@@ -43,8 +45,12 @@ 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
|
||||||
@@ -128,7 +134,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()
|
||||||
@@ -281,8 +287,8 @@ private extension ConfirmSeedPhraseView {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ConfirmSeedPhraseView(
|
ConfirmSeedPhraseView(
|
||||||
seedPhrase: ["abandon", "ability", "able", "about", "above", "absent",
|
seedPhrase: .constant(["abandon", "ability", "able", "about", "above", "absent",
|
||||||
"absorb", "abstract", "absurd", "abuse", "access", "accident"],
|
"absorb", "abstract", "absurd", "abuse", "access", "accident"]),
|
||||||
onConfirmed: {},
|
onConfirmed: {},
|
||||||
onBack: {}
|
onBack: {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ struct ImportSeedPhraseView: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
|
.background { RosettaColors.authBackground.ignoresSafeArea() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,21 +118,12 @@ 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: 12)
|
RoundedRectangle(cornerRadius: 14)
|
||||||
.fill(color.opacity(0.12))
|
.fill(color.opacity(0.15))
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(color.opacity(0.18), lineWidth: 0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +145,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)
|
||||||
.contentTransition(.symbolEffect(.replace))
|
.animation(.easeInOut(duration: 0.2), value: showCopiedToast)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(showCopiedToast ? "Copied to clipboard" : "Copy seed phrase to clipboard")
|
.accessibilityLabel(showCopiedToast ? "Copied to clipboard" : "Copy seed phrase to clipboard")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SetPasswordView: View {
|
struct SetPasswordView: View {
|
||||||
let seedPhrase: [String]
|
@Binding var seedPhrase: [String]
|
||||||
let isImportMode: Bool
|
let isImportMode: Bool
|
||||||
let onAccountCreated: () -> Void
|
let onAccountCreated: () -> Void
|
||||||
let onBack: () -> Void
|
let onBack: () -> Void
|
||||||
@@ -72,6 +72,7 @@ struct SetPasswordView: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
|
.background { RosettaColors.authBackground.ignoresSafeArea() }
|
||||||
.ignoresSafeArea(.keyboard)
|
.ignoresSafeArea(.keyboard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,8 +279,8 @@ private extension SetPasswordView {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SetPasswordView(
|
SetPasswordView(
|
||||||
seedPhrase: ["abandon", "ability", "able", "about", "above", "absent",
|
seedPhrase: .constant(["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: {}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ 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 }
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ struct ChatDetailView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@ObservedObject private var messageRepository = MessageRepository.shared
|
@ObservedObject private var messageRepository = MessageRepository.shared
|
||||||
@State private var dialogRepository = DialogRepository.shared
|
|
||||||
|
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@State private var sendError: String?
|
@State private var sendError: String?
|
||||||
|
@State private var isViewActive = false
|
||||||
@FocusState private var isInputFocused: Bool
|
@FocusState private var isInputFocused: Bool
|
||||||
|
|
||||||
private var currentPublicKey: String {
|
private var currentPublicKey: String {
|
||||||
@@ -17,7 +17,7 @@ struct ChatDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var dialog: Dialog? {
|
private var dialog: Dialog? {
|
||||||
dialogRepository.dialogs[route.publicKey]
|
DialogRepository.shared.dialogs[route.publicKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
private var messages: [ChatMessage] {
|
private var messages: [ChatMessage] {
|
||||||
@@ -106,18 +106,21 @@ struct ChatDetailView: View {
|
|||||||
.toolbar { chatDetailToolbar } // твой header тут
|
.toolbar { chatDetailToolbar } // твой header тут
|
||||||
.toolbar(.hidden, for: .tabBar)
|
.toolbar(.hidden, for: .tabBar)
|
||||||
.task {
|
.task {
|
||||||
|
isViewActive = true
|
||||||
// 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.
|
||||||
// Without this, DialogRepository update rebuilds ChatListView's ForEach
|
// Without this, DialogRepository update rebuilds ChatListView's ForEach
|
||||||
// mid-navigation, recreating the NavigationLink and canceling the push.
|
// mid-navigation, recreating the NavigationLink and canceling the push.
|
||||||
try? await Task.sleep(for: .milliseconds(600))
|
try? await Task.sleep(for: .milliseconds(600))
|
||||||
|
guard isViewActive else { return }
|
||||||
activateDialog()
|
activateDialog()
|
||||||
markDialogAsRead()
|
markDialogAsRead()
|
||||||
// Subscribe to opponent's online status (Android parity) — only after settled
|
// Subscribe to opponent's online status (Android parity) — only after settled
|
||||||
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
|
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
isViewActive = false
|
||||||
messageRepository.setDialogActive(route.publicKey, isActive: false)
|
messageRepository.setDialogActive(route.publicKey, isActive: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,16 +309,20 @@ private extension ChatDetailView {
|
|||||||
try? await Task.sleep(for: .milliseconds(120))
|
try? await Task.sleep(for: .milliseconds(120))
|
||||||
scrollToBottom(proxy: proxy, animated: false)
|
scrollToBottom(proxy: proxy, animated: false)
|
||||||
}
|
}
|
||||||
markDialogAsRead()
|
// markDialogAsRead() removed — already handled in .task with 600ms delay.
|
||||||
|
// Calling it here immediately mutates DialogRepository, triggering
|
||||||
|
// ChatListView ForEach rebuild mid-navigation and cancelling the push.
|
||||||
}
|
}
|
||||||
.onChange(of: messages.count) { _, _ in
|
.onChange(of: messages.count) { _, _ in
|
||||||
scrollToBottom(proxy: proxy, animated: true)
|
scrollToBottom(proxy: proxy, animated: true)
|
||||||
|
if isViewActive {
|
||||||
markDialogAsRead()
|
markDialogAsRead()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onChange(of: isInputFocused) { _, focused in
|
.onChange(of: isInputFocused) { _, focused in
|
||||||
guard focused else { return }
|
guard focused else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .milliseconds(80))
|
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||||
scrollToBottom(proxy: proxy, animated: true)
|
scrollToBottom(proxy: proxy, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,28 +338,33 @@ private extension ChatDetailView {
|
|||||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||||
let messageText = message.text.isEmpty ? " " : message.text
|
let messageText = message.text.isEmpty ? " " : message.text
|
||||||
|
|
||||||
// Text determines bubble width; timestamp overlays at bottom-trailing.
|
// Telegram-style compact bubble: inline time+status at bottom-trailing.
|
||||||
// minWidth ensures the bubble is wide enough for the timestamp row.
|
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
|
||||||
Text(messageText)
|
Text(messageText)
|
||||||
.font(.system(size: 16, weight: .regular))
|
.font(.system(size: 17, weight: .regular))
|
||||||
|
.tracking(-0.43)
|
||||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineSpacing(0)
|
.lineSpacing(0)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(.horizontal, 14)
|
.padding(.leading, 11)
|
||||||
.padding(.top, 8)
|
.padding(.trailing, outgoing ? 64 : 48)
|
||||||
.padding(.bottom, 22)
|
.padding(.vertical, 5)
|
||||||
.frame(minWidth: outgoing ? 90 : 70, alignment: .leading)
|
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||||
.overlay(alignment: .bottomTrailing) {
|
.overlay(alignment: .bottomTrailing) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 3) {
|
||||||
Text(messageTime(message.timestamp))
|
Text(messageTime(message.timestamp))
|
||||||
.font(.system(size: 12, weight: .regular))
|
.font(.system(size: 11, weight: .regular))
|
||||||
.foregroundStyle(outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(
|
||||||
|
outgoing
|
||||||
|
? Color.white.opacity(0.55)
|
||||||
|
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
|
||||||
|
)
|
||||||
|
|
||||||
if outgoing { deliveryIndicator(message.deliveryStatus) }
|
if outgoing { deliveryIndicator(message.deliveryStatus) }
|
||||||
}
|
}
|
||||||
.padding(.trailing, 14)
|
.padding(.trailing, 11)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 5)
|
||||||
}
|
}
|
||||||
.background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) }
|
.background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) }
|
||||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||||
@@ -529,8 +541,8 @@ private extension ChatDetailView {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View {
|
func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View {
|
||||||
let nearRadius: CGFloat = isTailVisible ? 6 : 17
|
let nearRadius: CGFloat = isTailVisible ? 8 : 18
|
||||||
let bubbleRadius: CGFloat = 17
|
let bubbleRadius: CGFloat = 18
|
||||||
let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill
|
let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
UnevenRoundedRectangle(
|
UnevenRoundedRectangle(
|
||||||
@@ -561,36 +573,23 @@ 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(.ultraThinMaterial)
|
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
|
||||||
.overlay(Capsule().stroke(border, lineWidth: 0.8))
|
.overlay(Capsule().stroke(border, lineWidth: 0.8))
|
||||||
case .circle:
|
case .circle:
|
||||||
Circle()
|
Circle()
|
||||||
.fill(.ultraThinMaterial)
|
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
|
||||||
.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(.ultraThinMaterial)
|
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
|
||||||
.overlay(rounded.stroke(border, lineWidth: 0.8))
|
.overlay(rounded.stroke(border, lineWidth: 0.8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions / utils
|
// MARK: - Actions / utils
|
||||||
|
|
||||||
@@ -625,12 +624,12 @@ private extension ChatDetailView {
|
|||||||
Image(systemName: "checkmark").offset(x: 3)
|
Image(systemName: "checkmark").offset(x: 3)
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
.font(.system(size: 10.5, weight: .semibold))
|
.font(.system(size: 9.5, weight: .semibold))
|
||||||
.foregroundStyle(deliveryTint(status))
|
.foregroundStyle(deliveryTint(status))
|
||||||
.frame(width: 13, alignment: .trailing)
|
.frame(width: 12, alignment: .trailing)
|
||||||
default:
|
default:
|
||||||
Image(systemName: deliveryIcon(status))
|
Image(systemName: deliveryIcon(status))
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.system(size: 10, weight: .semibold))
|
||||||
.foregroundStyle(deliveryTint(status))
|
.foregroundStyle(deliveryTint(status))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,7 +669,7 @@ private extension ChatDetailView {
|
|||||||
func activateDialog() {
|
func activateDialog() {
|
||||||
// Only update existing dialogs; don't create ghost entries from search.
|
// Only update existing dialogs; don't create ghost entries from search.
|
||||||
// New dialogs are created when messages are sent/received (SessionManager).
|
// New dialogs are created when messages are sent/received (SessionManager).
|
||||||
if dialogRepository.dialogs[route.publicKey] != nil {
|
if DialogRepository.shared.dialogs[route.publicKey] != nil {
|
||||||
DialogRepository.shared.ensureDialog(
|
DialogRepository.shared.ensureDialog(
|
||||||
opponentKey: route.publicKey,
|
opponentKey: route.publicKey,
|
||||||
title: route.title,
|
title: route.title,
|
||||||
|
|||||||
@@ -1,16 +1,40 @@
|
|||||||
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Navigation State (survives parent re-renders)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ChatListNavigationState: ObservableObject {
|
||||||
|
@Published var path: [ChatRoute] = []
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ChatListView
|
// MARK: - ChatListView
|
||||||
|
|
||||||
|
/// The root chat list screen.
|
||||||
|
///
|
||||||
|
/// **IMPORTANT:** This view's `body` must NOT read any `@Observable` singleton
|
||||||
|
/// (`ProtocolManager`, `DialogRepository`, `AccountManager`, `SessionManager`)
|
||||||
|
/// directly. Such reads create implicit Observation tracking, causing the
|
||||||
|
/// NavigationStack to rebuild on every property change (e.g. during handshake)
|
||||||
|
/// and triggering "Update NavigationRequestObserver tried to update multiple
|
||||||
|
/// times per frame" → app freeze.
|
||||||
|
///
|
||||||
|
/// All `@Observable` access is isolated in dedicated child views:
|
||||||
|
/// - `DeviceVerificationBannersContainer` → `ProtocolManager`
|
||||||
|
/// - `ToolbarStoriesAvatar` → `AccountManager` / `SessionManager`
|
||||||
|
/// - `ChatListDialogContent` → `DialogRepository` (via ViewModel)
|
||||||
struct ChatListView: View {
|
struct ChatListView: View {
|
||||||
@Binding var isSearchActive: Bool
|
@Binding var isSearchActive: Bool
|
||||||
var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil
|
@Binding var isDetailPresented: Bool
|
||||||
@StateObject private var viewModel = ChatListViewModel()
|
@StateObject private var viewModel = ChatListViewModel()
|
||||||
|
@StateObject private var navigationState = ChatListNavigationState()
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var navigationPath: [ChatRoute] = []
|
|
||||||
|
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $navigationPath) {
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("🟡 ChatListView.body #\(Self._bodyCount)")
|
||||||
|
NavigationStack(path: $navigationState.path) {
|
||||||
ZStack {
|
ZStack {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
@@ -23,7 +47,7 @@ struct ChatListView: View {
|
|||||||
onOpenDialog: { route in
|
onOpenDialog: { route in
|
||||||
isSearchActive = false
|
isSearchActive = false
|
||||||
searchText = ""
|
searchText = ""
|
||||||
navigationPath.append(route)
|
navigationState.path.append(route)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -46,16 +70,16 @@ struct ChatListView: View {
|
|||||||
.navigationDestination(for: ChatRoute.self) { route in
|
.navigationDestination(for: ChatRoute.self) { route in
|
||||||
ChatDetailView(
|
ChatDetailView(
|
||||||
route: route,
|
route: route,
|
||||||
onPresentedChange: { isPresented in
|
onPresentedChange: { presented in
|
||||||
onChatDetailVisibilityChange?(isPresented)
|
isDetailPresented = presented
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
onChatDetailVisibilityChange?(!navigationPath.isEmpty)
|
isDetailPresented = !navigationState.path.isEmpty
|
||||||
}
|
}
|
||||||
.onChange(of: navigationPath) { _, newPath in
|
.onChange(of: navigationState.path) { _, newPath in
|
||||||
onChatDetailVisibilityChange?(!newPath.isEmpty)
|
isDetailPresented = !newPath.isEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(RosettaColors.figmaBlue)
|
.tint(RosettaColors.figmaBlue)
|
||||||
@@ -68,36 +92,129 @@ private extension ChatListView {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var normalContent: some View {
|
var normalContent: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
deviceVerificationBanners
|
// Isolated view — reads ProtocolManager (@Observable) without
|
||||||
|
// polluting ChatListView's observation scope.
|
||||||
|
DeviceVerificationBannersContainer()
|
||||||
|
|
||||||
|
// Isolated view — reads DialogRepository (@Observable) via viewModel
|
||||||
|
// without polluting ChatListView's observation scope.
|
||||||
|
ChatListDialogContent(
|
||||||
|
viewModel: viewModel,
|
||||||
|
navigationState: navigationState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar
|
||||||
|
|
||||||
|
private extension ChatListView {
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
var toolbarContent: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button { } label: {
|
||||||
|
Text("Edit")
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
// Isolated view — reads AccountManager & SessionManager (@Observable)
|
||||||
|
// without polluting ChatListView's observation scope.
|
||||||
|
ToolbarStoriesAvatar()
|
||||||
|
Text("Chats")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button { } label: {
|
||||||
|
Image(systemName: "camera")
|
||||||
|
.font(.system(size: 16, weight: .regular))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Camera")
|
||||||
|
Button { } label: {
|
||||||
|
Image(systemName: "square.and.pencil")
|
||||||
|
.font(.system(size: 17, weight: .regular))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
.accessibilityLabel("New chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar Stories Avatar (observation-isolated)
|
||||||
|
|
||||||
|
/// Reads `AccountManager` and `SessionManager` in its own observation scope.
|
||||||
|
/// Changes to these `@Observable` singletons only re-render this small view,
|
||||||
|
/// not the parent ChatListView / NavigationStack.
|
||||||
|
private struct ToolbarStoriesAvatar: View {
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
|
var body: some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("🟣 ToolbarStoriesAvatar.body #\(Self._bodyCount)")
|
||||||
|
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
|
||||||
|
let initials = RosettaColors.initials(
|
||||||
|
name: SessionManager.shared.displayName, publicKey: pk
|
||||||
|
)
|
||||||
|
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
|
||||||
|
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device Verification Banners (observation-isolated)
|
||||||
|
|
||||||
|
/// Reads `ProtocolManager` in its own observation scope.
|
||||||
|
/// During handshake, `connectionState` changes 4+ times rapidly — this view
|
||||||
|
/// absorbs those re-renders instead of cascading them to the NavigationStack.
|
||||||
|
private struct DeviceVerificationBannersContainer: View {
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
|
var body: some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("⚪ DeviceVerificationBanners.body #\(Self._bodyCount)")
|
||||||
|
let proto = ProtocolManager.shared
|
||||||
|
|
||||||
|
if proto.connectionState == .deviceVerificationRequired {
|
||||||
|
DeviceWaitingApprovalBanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pendingDevice = proto.pendingDeviceVerification {
|
||||||
|
DeviceApprovalBanner(
|
||||||
|
device: pendingDevice,
|
||||||
|
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
|
||||||
|
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dialog Content (observation-isolated)
|
||||||
|
|
||||||
|
/// Reads `DialogRepository` (via ViewModel) in its own observation scope.
|
||||||
|
/// Changes to dialogs only re-render this list, not the NavigationStack.
|
||||||
|
private struct ChatListDialogContent: View {
|
||||||
|
@ObservedObject var viewModel: ChatListViewModel
|
||||||
|
@ObservedObject var navigationState: ChatListNavigationState
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)")
|
||||||
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
||||||
ChatEmptyStateView(searchText: "")
|
ChatEmptyStateView(searchText: "")
|
||||||
} else {
|
} else {
|
||||||
dialogList
|
dialogList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
private var dialogList: some View {
|
||||||
var deviceVerificationBanners: some View {
|
|
||||||
let protocol_ = ProtocolManager.shared
|
|
||||||
|
|
||||||
// Banner 1: THIS device needs approval from another device
|
|
||||||
if protocol_.connectionState == .deviceVerificationRequired {
|
|
||||||
DeviceWaitingApprovalBanner()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Banner 2: ANOTHER device needs approval from THIS device
|
|
||||||
if let pendingDevice = protocol_.pendingDeviceVerification {
|
|
||||||
DeviceApprovalBanner(
|
|
||||||
device: pendingDevice,
|
|
||||||
onAccept: { protocol_.acceptDevice(pendingDevice.deviceId) },
|
|
||||||
onDecline: { protocol_.declineDevice(pendingDevice.deviceId) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var dialogList: some View {
|
|
||||||
List {
|
List {
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ForEach(0..<8, id: \.self) { _ in
|
ForEach(0..<8, id: \.self) { _ in
|
||||||
@@ -128,9 +245,9 @@ private extension ChatListView {
|
|||||||
.scrollDismissesKeyboard(.immediately)
|
.scrollDismissesKeyboard(.immediately)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatRow(_ dialog: Dialog) -> some View {
|
private func chatRow(_ dialog: Dialog) -> some View {
|
||||||
Button {
|
Button {
|
||||||
navigationPath.append(ChatRoute(dialog: dialog))
|
navigationState.path.append(ChatRoute(dialog: dialog))
|
||||||
} label: {
|
} label: {
|
||||||
ChatRowView(dialog: dialog)
|
ChatRowView(dialog: dialog)
|
||||||
}
|
}
|
||||||
@@ -172,58 +289,6 @@ private extension ChatListView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toolbar
|
|
||||||
|
|
||||||
private extension ChatListView {
|
|
||||||
@ToolbarContentBuilder
|
|
||||||
var toolbarContent: some ToolbarContent {
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
Button { } label: {
|
|
||||||
Text("Edit")
|
|
||||||
.font(.system(size: 17, weight: .medium))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolbarItem(placement: .principal) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
storiesAvatars
|
|
||||||
Text("Chats")
|
|
||||||
.font(.system(size: 17, weight: .semibold))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Button { } label: {
|
|
||||||
Image(systemName: "camera")
|
|
||||||
.font(.system(size: 16, weight: .regular))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Camera")
|
|
||||||
Button { } label: {
|
|
||||||
Image(systemName: "square.and.pencil")
|
|
||||||
.font(.system(size: 17, weight: .regular))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 2)
|
|
||||||
.accessibilityLabel("New chat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var storiesAvatars: some View {
|
|
||||||
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
|
|
||||||
let initials = RosettaColors.initials(
|
|
||||||
name: SessionManager.shared.displayName, publicKey: pk
|
|
||||||
)
|
|
||||||
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
|
|
||||||
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Device Waiting Approval Banner
|
// MARK: - Device Waiting Approval Banner
|
||||||
|
|
||||||
/// Shown when THIS device needs approval from another Rosetta device.
|
/// Shown when THIS device needs approval from another Rosetta device.
|
||||||
@@ -303,4 +368,4 @@ private struct DeviceApprovalBanner: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview { ChatListView(isSearchActive: .constant(false), onChatDetailVisibilityChange: nil) }
|
#Preview { ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false)) }
|
||||||
|
|||||||
@@ -103,15 +103,8 @@ 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)")
|
||||||
for user in packet.users {
|
// Note: DialogRepository.updateUserInfo is handled by
|
||||||
DialogRepository.shared.updateUserInfo(
|
// SessionManager.setupUserInfoSearchHandler — avoid duplicate mutations.
|
||||||
publicKey: user.publicKey,
|
|
||||||
title: user.title,
|
|
||||||
username: user.username,
|
|
||||||
verified: user.verified,
|
|
||||||
online: user.online
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
struct SearchResultsSection: View {
|
struct SearchResultsSection: View {
|
||||||
let isSearching: Bool
|
let isSearching: Bool
|
||||||
let searchResults: [SearchUser]
|
let searchResults: [SearchUser]
|
||||||
|
let currentPublicKey: String
|
||||||
var onSelectUser: (SearchUser) -> Void
|
var onSelectUser: (SearchUser) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -58,7 +59,7 @@ private extension SearchResultsSection {
|
|||||||
|
|
||||||
private extension SearchResultsSection {
|
private extension SearchResultsSection {
|
||||||
func searchResultRow(_ user: SearchUser) -> some View {
|
func searchResultRow(_ user: SearchUser) -> some View {
|
||||||
let isSelf = user.publicKey == SessionManager.shared.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.publicKey)
|
||||||
|
|
||||||
@@ -118,6 +119,7 @@ private extension SearchResultsSection {
|
|||||||
SearchResultsSection(
|
SearchResultsSection(
|
||||||
isSearching: false,
|
isSearching: false,
|
||||||
searchResults: [],
|
searchResults: [],
|
||||||
|
currentPublicKey: "",
|
||||||
onSelectUser: { _ in }
|
onSelectUser: { _ in }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import SwiftUI
|
|||||||
// MARK: - SearchView
|
// MARK: - SearchView
|
||||||
|
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil
|
@Binding var isDetailPresented: Bool
|
||||||
@State private var viewModel = SearchViewModel()
|
@StateObject private var viewModel = SearchViewModel()
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var navigationPath: [ChatRoute] = []
|
@State private var navigationPath: [ChatRoute] = []
|
||||||
|
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
|
||||||
NavigationStack(path: $navigationPath) {
|
NavigationStack(path: $navigationPath) {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
@@ -17,8 +20,11 @@ struct SearchView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
favoriteContactsRow
|
FavoriteContactsRow(navigationPath: $navigationPath)
|
||||||
recentSection
|
RecentSection(
|
||||||
|
viewModel: viewModel,
|
||||||
|
navigationPath: $navigationPath
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
searchResultsContent
|
searchResultsContent
|
||||||
}
|
}
|
||||||
@@ -37,15 +43,15 @@ struct SearchView: View {
|
|||||||
ChatDetailView(
|
ChatDetailView(
|
||||||
route: route,
|
route: route,
|
||||||
onPresentedChange: { isPresented in
|
onPresentedChange: { isPresented in
|
||||||
onChatDetailVisibilityChange?(isPresented)
|
isDetailPresented = isPresented
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
onChatDetailVisibilityChange?(!navigationPath.isEmpty)
|
isDetailPresented = !navigationPath.isEmpty
|
||||||
}
|
}
|
||||||
.onChange(of: navigationPath) { _, newPath in
|
.onChange(of: navigationPath) { _, newPath in
|
||||||
onChatDetailVisibilityChange?(!newPath.isEmpty)
|
isDetailPresented = !newPath.isEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,11 +134,17 @@ private extension SearchView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Favorite Contacts (Figma: horizontal scroll at top)
|
// MARK: - Favorite Contacts (isolated — reads DialogRepository in own scope)
|
||||||
|
|
||||||
private extension SearchView {
|
/// Isolated child view so that `DialogRepository.shared.sortedDialogs` observation
|
||||||
@ViewBuilder
|
/// does NOT propagate to `SearchView`'s NavigationStack.
|
||||||
var favoriteContactsRow: some View {
|
private struct FavoriteContactsRow: View {
|
||||||
|
@Binding var navigationPath: [ChatRoute]
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
|
||||||
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
||||||
if !dialogs.isEmpty {
|
if !dialogs.isEmpty {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
@@ -169,16 +181,22 @@ private extension SearchView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Recent Section
|
// MARK: - Recent Section (isolated — reads SessionManager in own scope)
|
||||||
|
|
||||||
private extension SearchView {
|
/// Isolated child view so that `SessionManager.shared.currentPublicKey` observation
|
||||||
@ViewBuilder
|
/// does NOT propagate to `SearchView`'s NavigationStack.
|
||||||
var recentSection: some View {
|
private struct RecentSection: View {
|
||||||
|
@ObservedObject var viewModel: SearchViewModel
|
||||||
|
@Binding var navigationPath: [ChatRoute]
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
|
||||||
if viewModel.recentSearches.isEmpty {
|
if viewModel.recentSearches.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Section header
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("RECENT")
|
Text("RECENT")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
@@ -198,7 +216,6 @@ private extension SearchView {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
// Recent items
|
|
||||||
ForEach(viewModel.recentSearches, id: \.publicKey) { user in
|
ForEach(viewModel.recentSearches, id: \.publicKey) { user in
|
||||||
recentRow(user)
|
recentRow(user)
|
||||||
}
|
}
|
||||||
@@ -206,7 +223,7 @@ private extension SearchView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
LottieView(
|
LottieView(
|
||||||
animationName: "search",
|
animationName: "search",
|
||||||
@@ -228,8 +245,9 @@ private extension SearchView {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func recentRow(_ user: RecentSearch) -> some View {
|
private func recentRow(_ user: RecentSearch) -> some View {
|
||||||
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
|
let currentPK = SessionManager.shared.currentPublicKey
|
||||||
|
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.publicKey)
|
||||||
|
|
||||||
@@ -245,7 +263,6 @@ private extension SearchView {
|
|||||||
isSavedMessages: isSelf
|
isSavedMessages: isSelf
|
||||||
)
|
)
|
||||||
|
|
||||||
// Close button to remove from recent
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.removeRecentSearch(publicKey: user.publicKey)
|
viewModel.removeRecentSearch(publicKey: user.publicKey)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -288,6 +305,7 @@ private extension SearchView {
|
|||||||
SearchResultsSection(
|
SearchResultsSection(
|
||||||
isSearching: viewModel.isSearching,
|
isSearching: viewModel.isSearching,
|
||||||
searchResults: viewModel.searchResults,
|
searchResults: viewModel.searchResults,
|
||||||
|
currentPublicKey: SessionManager.shared.currentPublicKey,
|
||||||
onSelectUser: { user in
|
onSelectUser: { user in
|
||||||
viewModel.addToRecent(user)
|
viewModel.addToRecent(user)
|
||||||
navigationPath.append(ChatRoute(user: user))
|
navigationPath.append(ChatRoute(user: user))
|
||||||
@@ -299,5 +317,5 @@ private extension SearchView {
|
|||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SearchView(onChatDetailVisibilityChange: nil)
|
SearchView(isDetailPresented: .constant(false))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,26 @@ import os
|
|||||||
|
|
||||||
// MARK: - SearchViewModel
|
// MARK: - SearchViewModel
|
||||||
|
|
||||||
@Observable
|
/// Search view model with **cached** state.
|
||||||
|
///
|
||||||
|
/// Uses `ObservableObject` + `@Published` (NOT `@Observable`) to avoid
|
||||||
|
/// SwiftUI observation feedback loops when embedded inside a NavigationStack
|
||||||
|
/// within the tab pager. `@Observable` + `@State` caused infinite body
|
||||||
|
/// re-evaluations of SearchView (hundreds per second → 99 % CPU freeze).
|
||||||
|
/// `ObservableObject` + `@StateObject` matches ChatListViewModel and
|
||||||
|
/// SettingsViewModel which are both stable.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SearchViewModel {
|
final class SearchViewModel: ObservableObject {
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Search")
|
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Search")
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - State
|
||||||
|
|
||||||
var searchQuery = ""
|
@Published var searchQuery = ""
|
||||||
|
|
||||||
private(set) var searchResults: [SearchUser] = []
|
@Published private(set) var searchResults: [SearchUser] = []
|
||||||
private(set) var isSearching = false
|
@Published private(set) var isSearching = false
|
||||||
private(set) var recentSearches: [RecentSearch] = []
|
@Published private(set) var recentSearches: [RecentSearch] = []
|
||||||
|
|
||||||
private var searchTask: Task<Void, Never>?
|
private var searchTask: Task<Void, Never>?
|
||||||
private var lastSearchedText = ""
|
private var lastSearchedText = ""
|
||||||
@@ -51,34 +58,28 @@ final class SearchViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if trimmed == lastSearchedText {
|
if trimmed == lastSearchedText {
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isSearching = true
|
isSearching = true
|
||||||
|
|
||||||
|
|
||||||
// Debounce 1 second (like Android)
|
// Debounce 1 second (like Android)
|
||||||
searchTask = Task { [weak self] in
|
searchTask = Task { [weak self] in
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
|
||||||
guard let self, !Task.isCancelled else {
|
guard let self, !Task.isCancelled else {
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||||
guard !currentQuery.isEmpty, currentQuery == trimmed else {
|
guard !currentQuery.isEmpty, currentQuery == trimmed else {
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let connState = ProtocolManager.shared.connectionState
|
let connState = ProtocolManager.shared.connectionState
|
||||||
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
||||||
|
|
||||||
|
|
||||||
guard connState == .authenticated, let hash else {
|
guard connState == .authenticated, let hash else {
|
||||||
|
|
||||||
self.isSearching = false
|
self.isSearching = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -112,15 +113,27 @@ final class SearchViewModel {
|
|||||||
searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
|
searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
guard !self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
let query = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !query.isEmpty else {
|
||||||
self.isSearching = false
|
self.isSearching = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.searchResults = packet.users
|
// Merge server results with client-side public key matches.
|
||||||
|
// Server only matches by username; public key matching is local
|
||||||
|
// (same approach as Android).
|
||||||
|
var merged = packet.users
|
||||||
|
let serverKeys = Set(merged.map(\.publicKey))
|
||||||
|
|
||||||
|
let localMatches = self.findLocalPublicKeyMatches(query: query)
|
||||||
|
for match in localMatches where !serverKeys.contains(match.publicKey) {
|
||||||
|
merged.append(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.searchResults = merged
|
||||||
self.isSearching = false
|
self.isSearching = false
|
||||||
|
|
||||||
// Update dialog info from results
|
// Update dialog info from server results
|
||||||
for user in packet.users {
|
for user in packet.users {
|
||||||
DialogRepository.shared.updateUserInfo(
|
DialogRepository.shared.updateUserInfo(
|
||||||
publicKey: user.publicKey,
|
publicKey: user.publicKey,
|
||||||
@@ -134,6 +147,52 @@ final class SearchViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Client-Side Public Key Matching
|
||||||
|
|
||||||
|
/// Matches the query against local dialogs' public keys and the user's own
|
||||||
|
/// key (Saved Messages). The server only searches by username, so public
|
||||||
|
/// key look-ups must happen on the client (matches Android behaviour).
|
||||||
|
private func findLocalPublicKeyMatches(query: String) -> [SearchUser] {
|
||||||
|
let normalized = query.lowercased().replacingOccurrences(of: "0x", with: "")
|
||||||
|
|
||||||
|
// Only treat as a public key search when every character is hex
|
||||||
|
guard !normalized.isEmpty, normalized.allSatisfy(\.isHexDigit) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var results: [SearchUser] = []
|
||||||
|
|
||||||
|
// Check own public key → Saved Messages
|
||||||
|
let ownKey = SessionManager.shared.currentPublicKey.lowercased().replacingOccurrences(of: "0x", with: "")
|
||||||
|
if ownKey.hasPrefix(normalized) || ownKey == normalized {
|
||||||
|
results.append(SearchUser(
|
||||||
|
username: "",
|
||||||
|
title: "Saved Messages",
|
||||||
|
publicKey: SessionManager.shared.currentPublicKey,
|
||||||
|
verified: 0,
|
||||||
|
online: 1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local dialogs
|
||||||
|
for dialog in DialogRepository.shared.dialogs.values {
|
||||||
|
let dialogKey = dialog.opponentKey.lowercased().replacingOccurrences(of: "0x", with: "")
|
||||||
|
guard dialogKey.hasPrefix(normalized) || dialogKey == normalized else { continue }
|
||||||
|
// Skip if it's our own key (already handled as Saved Messages)
|
||||||
|
guard dialog.opponentKey != SessionManager.shared.currentPublicKey else { continue }
|
||||||
|
|
||||||
|
results.append(SearchUser(
|
||||||
|
username: dialog.opponentUsername,
|
||||||
|
title: dialog.opponentTitle,
|
||||||
|
publicKey: dialog.opponentKey,
|
||||||
|
verified: dialog.verified,
|
||||||
|
online: dialog.isOnline ? 1 : 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
private func normalizeSearchInput(_ input: String) -> String {
|
private func normalizeSearchInput(_ input: String) -> String {
|
||||||
input.replacingOccurrences(of: "@", with: "")
|
input.replacingOccurrences(of: "@", with: "")
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -5,53 +5,23 @@ struct MainTabView: View {
|
|||||||
var onLogout: (() -> Void)?
|
var onLogout: (() -> Void)?
|
||||||
@State private var selectedTab: RosettaTab = .chats
|
@State private var selectedTab: RosettaTab = .chats
|
||||||
@State private var isChatSearchActive = false
|
@State private var isChatSearchActive = false
|
||||||
@State private var tabSwipeState: TabBarSwipeState?
|
|
||||||
@State private var isChatListDetailPresented = false
|
@State private var isChatListDetailPresented = false
|
||||||
@State private var isSearchDetailPresented = false
|
@State private var isSearchDetailPresented = false
|
||||||
|
/// All tabs are pre-activated so that switching only changes the offset,
|
||||||
|
/// not the view structure. Creating a NavigationStack mid-animation causes
|
||||||
|
/// "Update NavigationRequestObserver tried to update multiple times per frame" → freeze.
|
||||||
|
@State private var activatedTabs: Set<RosettaTab> = Set(RosettaTab.interactionOrder)
|
||||||
|
/// When non-nil, the tab bar is being dragged and the pager follows interactively.
|
||||||
|
@State private var dragFractionalIndex: CGFloat?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
let _ = Self._bodyCount += 1
|
||||||
if #available(iOS 26.0, *) {
|
let _ = print("🔴 MainTabView.body #\(Self._bodyCount) search=\(isChatSearchActive) chatDetail=\(isChatListDetailPresented) searchDetail=\(isSearchDetailPresented)")
|
||||||
systemTabView
|
mainTabView
|
||||||
} else {
|
|
||||||
legacyTabView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
|
|
||||||
@available(iOS 26.0, *)
|
private var mainTabView: some View {
|
||||||
private var systemTabView: some View {
|
|
||||||
TabView(selection: $selectedTab) {
|
|
||||||
ChatListView(
|
|
||||||
isSearchActive: $isChatSearchActive,
|
|
||||||
onChatDetailVisibilityChange: { isPresented in
|
|
||||||
isChatListDetailPresented = isPresented
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.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(onChatDetailVisibilityChange: { isPresented in
|
|
||||||
isSearchDetailPresented = isPresented
|
|
||||||
})
|
|
||||||
.tabItem {
|
|
||||||
Label(RosettaTab.search.label, systemImage: RosettaTab.search.icon)
|
|
||||||
}
|
|
||||||
.tag(RosettaTab.search)
|
|
||||||
}
|
|
||||||
.tint(RosettaColors.primaryBlue)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var legacyTabView: some View {
|
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
@@ -64,32 +34,35 @@ struct MainTabView: View {
|
|||||||
RosettaTabBar(
|
RosettaTabBar(
|
||||||
selectedTab: selectedTab,
|
selectedTab: selectedTab,
|
||||||
onTabSelected: { tab in
|
onTabSelected: { tab in
|
||||||
tabSwipeState = nil
|
activatedTabs.insert(tab)
|
||||||
|
// Activate adjacent tabs for smooth paging
|
||||||
|
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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSwipeStateChanged: { state in
|
onSwipeStateChanged: { state in
|
||||||
tabSwipeState = state
|
if let state {
|
||||||
},
|
// Activate all main tabs during drag for smooth paging
|
||||||
badges: tabBadges
|
for tab in RosettaTab.interactionOrder {
|
||||||
|
activatedTabs.insert(tab)
|
||||||
|
}
|
||||||
|
dragFractionalIndex = state.fractionalIndex
|
||||||
|
} else {
|
||||||
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
||||||
|
dragFractionalIndex = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.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 {
|
|
||||||
tabSwipeState = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentPageIndex: CGFloat {
|
private var currentPageIndex: CGFloat {
|
||||||
if let tabSwipeState {
|
CGFloat(selectedTab.interactionIndex)
|
||||||
return max(0, min(CGFloat(RosettaTab.interactionOrder.count - 1), tabSwipeState.fractionalIndex))
|
|
||||||
}
|
|
||||||
return CGFloat(selectedTab.interactionIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -97,6 +70,8 @@ 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)
|
||||||
@@ -104,27 +79,30 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: totalWidth, alignment: .leading)
|
.frame(width: totalWidth, alignment: .leading)
|
||||||
.offset(x: -currentPageIndex * width)
|
.modifier(PagerOffsetModifier(
|
||||||
.animation(tabSwipeState == nil ? .spring(response: 0.34, dampingFraction: 0.82) : nil, value: currentPageIndex)
|
effectiveIndex: dragFractionalIndex ?? currentPageIndex,
|
||||||
|
pageWidth: width,
|
||||||
|
isDragging: dragFractionalIndex != nil
|
||||||
|
))
|
||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func tabView(for tab: RosettaTab) -> some View {
|
private func tabView(for tab: RosettaTab) -> some View {
|
||||||
|
if activatedTabs.contains(tab) {
|
||||||
switch tab {
|
switch tab {
|
||||||
case .chats:
|
case .chats:
|
||||||
ChatListView(
|
ChatListView(
|
||||||
isSearchActive: $isChatSearchActive,
|
isSearchActive: $isChatSearchActive,
|
||||||
onChatDetailVisibilityChange: { isPresented in
|
isDetailPresented: $isChatListDetailPresented
|
||||||
isChatListDetailPresented = isPresented
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView(onLogout: onLogout)
|
SettingsView(onLogout: onLogout)
|
||||||
case .search:
|
case .search:
|
||||||
SearchView(onChatDetailVisibilityChange: { isPresented in
|
SearchView(isDetailPresented: $isSearchDetailPresented)
|
||||||
isSearchDetailPresented = isPresented
|
}
|
||||||
})
|
} else {
|
||||||
|
RosettaColors.Adaptive.background
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,32 +110,27 @@ 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 {
|
// MARK: - Pager Offset Modifier
|
||||||
@ViewBuilder
|
|
||||||
func badgeIfNeeded(_ value: String?) -> some View {
|
/// Isolates the offset/animation from child view identity so that
|
||||||
if let value {
|
/// changing `effectiveIndex` only redraws the transform, not the child views.
|
||||||
badge(value)
|
private struct PagerOffsetModifier: ViewModifier {
|
||||||
} else {
|
let effectiveIndex: CGFloat
|
||||||
self
|
let pageWidth: CGFloat
|
||||||
}
|
let isDragging: Bool
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("⬛ PagerOffset.body #\(Self._bodyCount) idx=\(effectiveIndex) w=\(pageWidth)")
|
||||||
|
content
|
||||||
|
.offset(x: -effectiveIndex * pageWidth)
|
||||||
|
.animation(
|
||||||
|
isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82),
|
||||||
|
value: effectiveIndex
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +168,7 @@ struct PlaceholderTabView: View {
|
|||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,19 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
let count: Int
|
let count: Int
|
||||||
let buildPage: (Int) -> Page
|
let buildPage: (Int) -> Page
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
func makeCoordinator() -> OnboardingPagerCoordinator {
|
||||||
|
OnboardingPagerCoordinator(
|
||||||
|
currentIndex: currentIndex,
|
||||||
|
count: count,
|
||||||
|
currentIndexSetter: { [self] idx in self._currentIndex.wrappedValue = idx },
|
||||||
|
continuousProgressSetter: { [self] val in self._continuousProgress.wrappedValue = val },
|
||||||
|
buildControllers: { (0..<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(
|
||||||
@@ -36,29 +48,41 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIViewController(_ vc: UIPageViewController, context: Context) {
|
func updateUIViewController(_ vc: UIPageViewController, context: Context) {
|
||||||
context.coordinator.parent = self
|
context.coordinator.count = count
|
||||||
|
context.coordinator.currentIndex = currentIndex
|
||||||
|
context.coordinator.currentIndexSetter = { [self] idx in self._currentIndex.wrappedValue = idx }
|
||||||
|
context.coordinator.continuousProgressSetter = { [self] val in self._continuousProgress.wrappedValue = val }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Coordinator
|
// MARK: - Coordinator (non-generic to avoid Swift compiler crash in Release optimiser)
|
||||||
|
|
||||||
final class Coordinator: NSObject,
|
final class OnboardingPagerCoordinator: NSObject,
|
||||||
UIPageViewControllerDataSource,
|
UIPageViewControllerDataSource,
|
||||||
UIPageViewControllerDelegate,
|
UIPageViewControllerDelegate,
|
||||||
UIScrollViewDelegate
|
UIScrollViewDelegate
|
||||||
{
|
{
|
||||||
var parent: OnboardingPager
|
let controllers: [UIViewController]
|
||||||
let controllers: [UIHostingController<Page>]
|
var count: Int
|
||||||
|
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(_ parent: OnboardingPager) {
|
init(
|
||||||
self.parent = parent
|
currentIndex: Int,
|
||||||
self.pendingIndex = parent.currentIndex
|
count: Int,
|
||||||
self.controllers = (0..<parent.count).map { i in
|
currentIndexSetter: @escaping (Int) -> Void,
|
||||||
let hc = UIHostingController(rootView: parent.buildPage(i))
|
continuousProgressSetter: @escaping (CGFloat) -> Void,
|
||||||
hc.view.backgroundColor = .clear
|
buildControllers: () -> [UIViewController]
|
||||||
return hc
|
) {
|
||||||
}
|
self.currentIndex = currentIndex
|
||||||
|
self.count = count
|
||||||
|
self.currentIndexSetter = currentIndexSetter
|
||||||
|
self.continuousProgressSetter = continuousProgressSetter
|
||||||
|
self.pendingIndex = currentIndex
|
||||||
|
self.controllers = buildControllers()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: DataSource
|
// MARK: DataSource
|
||||||
@@ -76,7 +100,7 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
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 < parent.count - 1 else { return nil }
|
idx < count - 1 else { return nil }
|
||||||
return controllers[idx + 1]
|
return controllers[idx + 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +125,8 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
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 }
|
||||||
parent.currentIndex = idx
|
currentIndex = idx
|
||||||
|
currentIndexSetter(idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: ScrollView — real-time progress
|
// MARK: ScrollView — real-time progress
|
||||||
@@ -109,12 +134,9 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
|||||||
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(parent.currentIndex) + fraction
|
let progress = CGFloat(currentIndex) + fraction
|
||||||
parent.continuousProgress = max(0, min(CGFloat(parent.count - 1), progress))
|
continuousProgressSetter(max(0, min(CGFloat(count - 1), progress)))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import SwiftUI
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
var onLogout: (() -> Void)?
|
var onLogout: (() -> Void)?
|
||||||
|
|
||||||
@State private var viewModel = SettingsViewModel()
|
@StateObject private var viewModel = SettingsViewModel()
|
||||||
@State private var showCopiedToast = false
|
@State private var showCopiedToast = false
|
||||||
@State private var showLogoutConfirmation = false
|
@State private var showLogoutConfirmation = false
|
||||||
|
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("🟢 SettingsView.body #\(Self._bodyCount)")
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -36,8 +39,9 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(RosettaColors.primaryBlue)
|
.foregroundStyle(RosettaColors.primaryBlue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
|
.task { viewModel.refresh() }
|
||||||
.alert("Log Out", isPresented: $showLogoutConfirmation) {
|
.alert("Log Out", isPresented: $showLogoutConfirmation) {
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
Button("Log Out", role: .destructive) {
|
Button("Log Out", role: .destructive) {
|
||||||
@@ -212,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(.ultraThinMaterial)
|
.background(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.padding(.top, 60)
|
.padding(.top, 60)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@Observable
|
/// Settings view model with **cached** state.
|
||||||
|
///
|
||||||
|
/// Previously this was `@Observable` with computed properties that read
|
||||||
|
/// `ProtocolManager`, `SessionManager`, and `AccountManager` directly.
|
||||||
|
/// Because all tabs are pre-activated, those reads caused SettingsView
|
||||||
|
/// (inside a NavigationStack) to re-render 6+ times during handshake,
|
||||||
|
/// producing "Update NavigationRequestObserver tried to update multiple
|
||||||
|
/// times per frame" → app freeze.
|
||||||
|
///
|
||||||
|
/// Now uses `ObservableObject` + `@Published` stored properties.
|
||||||
|
/// State is refreshed explicitly via `refresh()`.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SettingsViewModel {
|
final class SettingsViewModel: ObservableObject {
|
||||||
|
|
||||||
var displayName: String {
|
@Published private(set) var displayName: String = ""
|
||||||
SessionManager.shared.displayName.isEmpty
|
@Published private(set) var username: String = ""
|
||||||
? (AccountManager.shared.currentAccount?.displayName ?? "")
|
@Published private(set) var publicKey: String = ""
|
||||||
: SessionManager.shared.displayName
|
@Published private(set) var connectionStatus: String = "Disconnected"
|
||||||
}
|
@Published private(set) var isConnected: Bool = false
|
||||||
|
|
||||||
var username: String {
|
|
||||||
SessionManager.shared.username.isEmpty
|
|
||||||
? (AccountManager.shared.currentAccount?.username ?? "")
|
|
||||||
: SessionManager.shared.username
|
|
||||||
}
|
|
||||||
|
|
||||||
var publicKey: String {
|
|
||||||
AccountManager.shared.currentAccount?.publicKey ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var initials: String {
|
var initials: String {
|
||||||
RosettaColors.initials(name: displayName, publicKey: publicKey)
|
RosettaColors.initials(name: displayName, publicKey: publicKey)
|
||||||
@@ -30,24 +30,34 @@ final class SettingsViewModel {
|
|||||||
RosettaColors.avatarColorIndex(for: publicKey)
|
RosettaColors.avatarColorIndex(for: publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectionStatus: String {
|
/// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`.
|
||||||
switch ProtocolManager.shared.connectionState {
|
func refresh() {
|
||||||
case .disconnected: return "Disconnected"
|
let session = SessionManager.shared
|
||||||
case .connecting: return "Connecting..."
|
let account = AccountManager.shared.currentAccount
|
||||||
case .connected: return "Connected"
|
|
||||||
case .handshaking: return "Authenticating..."
|
|
||||||
case .deviceVerificationRequired: return "Device Verification Required"
|
|
||||||
case .authenticated: return "Online"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isConnected: Bool {
|
displayName = session.displayName.isEmpty
|
||||||
ProtocolManager.shared.connectionState == .authenticated
|
? (account?.displayName ?? "")
|
||||||
|
: session.displayName
|
||||||
|
|
||||||
|
username = session.username.isEmpty
|
||||||
|
? (account?.username ?? "")
|
||||||
|
: session.username
|
||||||
|
|
||||||
|
publicKey = account?.publicKey ?? ""
|
||||||
|
|
||||||
|
let state = ProtocolManager.shared.connectionState
|
||||||
|
isConnected = state == .authenticated
|
||||||
|
switch state {
|
||||||
|
case .disconnected: connectionStatus = "Disconnected"
|
||||||
|
case .connecting: connectionStatus = "Connecting..."
|
||||||
|
case .connected: connectionStatus = "Connected"
|
||||||
|
case .handshaking: connectionStatus = "Authenticating..."
|
||||||
|
case .deviceVerificationRequired: connectionStatus = "Device Verification Required"
|
||||||
|
case .authenticated: connectionStatus = "Online"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyPublicKey() {
|
func copyPublicKey() {
|
||||||
#if canImport(UIKit)
|
|
||||||
UIPasteboard.general.string = publicKey
|
UIPasteboard.general.string = publicKey
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Rosetta/Resources/Lottie/Images/back_5.png
Normal file
BIN
Rosetta/Resources/Lottie/Images/back_5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -16,7 +16,7 @@ private enum AppState {
|
|||||||
struct RosettaApp: App {
|
struct RosettaApp: App {
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
UIWindow.appearance().backgroundColor = .systemBackground
|
UIWindow.appearance().backgroundColor = .black
|
||||||
|
|
||||||
// Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not.
|
// Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not.
|
||||||
// If this is the first launch after install, clear any stale Keychain data.
|
// If this is the first launch after install, clear any stale Keychain data.
|
||||||
@@ -46,8 +46,11 @@ struct RosettaApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor static var _bodyCount = 0
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var rootView: some View {
|
private var rootView: some View {
|
||||||
|
let _ = Self._bodyCount += 1
|
||||||
|
let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(appState)")
|
||||||
switch appState {
|
switch appState {
|
||||||
case .splash:
|
case .splash:
|
||||||
SplashView {
|
SplashView {
|
||||||
|
|||||||
Reference in New Issue
Block a user