Исправления UI: центрирование Saved Messages, размеры тулбара звонков, отображение "Connecting...", локальная отправка в Saved Messages

This commit is contained in:
2026-03-11 01:41:40 +05:00
parent 0f5094df10
commit fa003e9edb
15 changed files with 913 additions and 155 deletions

View File

@@ -272,7 +272,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -288,7 +288,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.8; MARKETING_VERSION = 1.0.9;
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 = "";
@@ -311,7 +311,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -327,7 +327,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.8; MARKETING_VERSION = 1.0.9;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -1,27 +1,22 @@
import Foundation import Foundation
/// UserInfo packet (0x01) get/set user profile information. /// UserInfo packet (0x01) get/set user profile information.
/// Field order matches TypeScript server: username, avatar, title, privateKey. /// Field order matches Desktop: username, title, privateKey.
struct PacketUserInfo: Packet { struct PacketUserInfo: Packet {
static let packetId = 0x01 static let packetId = 0x01
var username: String = "" var username: String = ""
var avatar: String = ""
var title: String = "" var title: String = ""
var privateKey: String = "" var privateKey: String = ""
func write(to stream: Stream) { func write(to stream: Stream) {
// Send: username, avatar, title, privateKey (match TypeScript server)
stream.writeString(username) stream.writeString(username)
stream.writeString(avatar)
stream.writeString(title) stream.writeString(title)
stream.writeString(privateKey) stream.writeString(privateKey)
} }
mutating func read(from stream: Stream) { mutating func read(from stream: Stream) {
// Receive: username, avatar, title, privateKey (match TypeScript server)
username = stream.readString() username = stream.readString()
avatar = stream.readString()
title = stream.readString() title = stream.readString()
privateKey = stream.readString() privateKey = stream.readString()
} }

View File

@@ -152,6 +152,13 @@ final class SessionManager {
decryptedText: text decryptedText: text
) )
// Saved Messages: local-only, no server send
if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
return
}
// Send via WebSocket // Send via WebSocket
ProtocolManager.shared.sendPacket(packet) ProtocolManager.shared.sendPacket(packet)
registerOutgoingRetry(for: packet) registerOutgoingRetry(for: packet)
@@ -183,6 +190,12 @@ final class SessionManager {
sendReadReceipt(toPublicKey: toPublicKey, force: false) sendReadReceipt(toPublicKey: toPublicKey, force: false)
} }
/// Updates locally cached display name and username (called from ProfileEditView).
func updateDisplayNameAndUsername(displayName: String, username: String) {
self.displayName = displayName
self.username = username
}
/// Ends the session and disconnects. /// Ends the session and disconnects.
func endSession() { func endSession() {
ProtocolManager.shared.disconnect() ProtocolManager.shared.disconnect()
@@ -336,7 +349,6 @@ final class SessionManager {
if !name.isEmpty || !uname.isEmpty { if !name.isEmpty || !uname.isEmpty {
var userInfoPacket = PacketUserInfo() var userInfoPacket = PacketUserInfo()
userInfoPacket.username = uname userInfoPacket.username = uname
userInfoPacket.avatar = ""
userInfoPacket.title = name userInfoPacket.title = name
userInfoPacket.privateKey = hash userInfoPacket.privateKey = hash
ProtocolManager.shared.sendPacket(userInfoPacket) ProtocolManager.shared.sendPacket(userInfoPacket)

View File

@@ -71,7 +71,7 @@ struct AvatarView: View {
} }
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if isOnline { if isOnline && !isSavedMessages {
Circle() Circle()
.fill(RosettaColors.primaryBlue) .fill(RosettaColors.primaryBlue)
.frame(width: badgeSize, height: badgeSize) .frame(width: badgeSize, height: badgeSize)

View File

@@ -5,32 +5,32 @@ import UIKit
enum RosettaTab: CaseIterable, Sendable { enum RosettaTab: CaseIterable, Sendable {
case chats case chats
case calls
case settings case settings
case search
static let interactionOrder: [RosettaTab] = [.chats, .settings, .search] static let interactionOrder: [RosettaTab] = [.chats, .calls, .settings]
var label: String { var label: String {
switch self { switch self {
case .chats: return "Chats" case .chats: return "Chats"
case .calls: return "Calls"
case .settings: return "Settings" case .settings: return "Settings"
case .search: return "Search"
} }
} }
var icon: String { var icon: String {
switch self { switch self {
case .chats: return "bubble.left.and.bubble.right" case .chats: return "bubble.left.and.bubble.right"
case .calls: return "phone"
case .settings: return "gearshape" case .settings: return "gearshape"
case .search: return "magnifyingglass"
} }
} }
var selectedIcon: String { var selectedIcon: String {
switch self { switch self {
case .chats: return "bubble.left.and.bubble.right.fill" case .chats: return "bubble.left.and.bubble.right.fill"
case .calls: return "phone.fill"
case .settings: return "gearshape.fill" case .settings: return "gearshape.fill"
case .search: return "magnifyingglass"
} }
} }

View File

@@ -21,6 +21,7 @@ struct AuthCoordinator: View {
@State private var isImportMode = false @State private var isImportMode = false
@State private var navigationDirection: NavigationDirection = .forward @State private var navigationDirection: NavigationDirection = .forward
@State private var swipeOffset: CGFloat = 0 @State private var swipeOffset: CGFloat = 0
@State private var fadeOverlay: Bool = false
private var canSwipeBack: Bool { private var canSwipeBack: Bool {
currentScreen != .welcome currentScreen != .welcome
@@ -58,6 +59,14 @@ struct AuthCoordinator: View {
.id(currentScreen) .id(currentScreen)
.offset(x: swipeOffset) .offset(x: swipeOffset)
} }
.overlay {
// Fade-through-black overlay for smooth forward transitions.
Color.black
.ignoresSafeArea()
.opacity(fadeOverlay ? 1 : 0)
.allowsHitTesting(fadeOverlay)
.animation(.easeInOut(duration: 0.12), value: fadeOverlay)
}
.overlay(alignment: .leading) { .overlay(alignment: .leading) {
if canSwipeBack { if canSwipeBack {
Color.clear Color.clear
@@ -158,15 +167,20 @@ private extension AuthCoordinator {
private extension AuthCoordinator { private extension AuthCoordinator {
func navigateTo(_ screen: AuthScreen) { func navigateTo(_ screen: AuthScreen) {
guard !fadeOverlay else { return }
navigationDirection = .forward navigationDirection = .forward
withAnimation(.spring(response: 0.45, dampingFraction: 0.92)) { fadeOverlay = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 140_000_000)
currentScreen = screen currentScreen = screen
try? await Task.sleep(nanoseconds: 30_000_000)
fadeOverlay = false
} }
} }
func navigateBack(to screen: AuthScreen) { func navigateBack(to screen: AuthScreen) {
navigationDirection = .backward navigationDirection = .backward
withAnimation(.spring(response: 0.4, dampingFraction: 0.95)) { withAnimation(.spring(response: 0.5, dampingFraction: 0.9)) {
currentScreen = screen currentScreen = screen
} }
} }

View File

@@ -0,0 +1,362 @@
import Lottie
import SwiftUI
// MARK: - Call Type
private enum CallType {
case outgoing
case incoming
case missed
var label: String {
switch self {
case .outgoing: return "Outgoing"
case .incoming: return "Incoming"
case .missed: return "Missed"
}
}
/// Small direction icon shown to the left of the avatar.
var directionIcon: String {
switch self {
case .outgoing: return "phone.arrow.up.right"
case .incoming: return "phone.arrow.down.left"
case .missed: return "phone.arrow.down.left"
}
}
var directionColor: Color {
switch self {
case .outgoing, .incoming: return RosettaColors.success
case .missed: return RosettaColors.error
}
}
var isMissed: Bool { self == .missed }
}
// MARK: - Call Entry
private struct CallEntry: Identifiable {
let id = UUID()
let name: String
let initials: String
let colorIndex: Int
let types: [CallType]
let duration: String?
let date: String
init(
name: String,
initials: String,
colorIndex: Int,
types: [CallType],
duration: String? = nil,
date: String
) {
self.name = name
self.initials = initials
self.colorIndex = colorIndex
self.types = types
self.duration = duration
self.date = date
}
var isMissed: Bool { types.contains { $0.isMissed } }
var primaryType: CallType { types.first ?? .outgoing }
var subtitleText: String {
let labels = types.map(\.label)
let joined = labels.joined(separator: ", ")
if let duration { return "\(joined) (\(duration))" }
return joined
}
}
// MARK: - Filter
private enum CallFilter: String, CaseIterable {
case all = "All"
case missed = "Missed"
}
// MARK: - CallsView
struct CallsView: View {
@State private var selectedFilter: CallFilter = .all
/// Empty by default real calls will come from backend later.
/// Mock data is only in #Preview.
fileprivate var recentCalls: [CallEntry] = []
private var filteredCalls: [CallEntry] {
switch selectedFilter {
case .all: return recentCalls
case .missed: return recentCalls.filter { $0.isMissed }
}
}
var body: some View {
NavigationStack {
Group {
if filteredCalls.isEmpty {
emptyStateContent
} else {
callListContent
}
}
.background(RosettaColors.Adaptive.background)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {} label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(height: 44)
.padding(.horizontal, 12)
}
.buttonStyle(.plain)
.glassCapsule()
}
ToolbarItem(placement: .principal) {
filterPicker
}
}
.toolbarBackground(.hidden, for: .navigationBar)
}
}
}
// MARK: - Empty State
private extension CallsView {
var emptyStateContent: some View {
VStack(spacing: 0) {
Spacer()
LottieView(
animationName: "phone_duck",
loopMode: .playOnce,
animationSpeed: 1.0
)
.frame(width: 200, height: 200)
Spacer().frame(height: 24)
Text("Your recent voice and video calls will\nappear here.")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
Spacer().frame(height: 20)
Button {} label: {
HStack(spacing: 8) {
Image(systemName: "phone.badge.plus")
.font(.system(size: 18))
Text("Start New Call")
.font(.system(size: 17))
}
.foregroundStyle(RosettaColors.primaryBlue)
}
.buttonStyle(.plain)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: -40)
}
}
// MARK: - Call List Content
private extension CallsView {
var callListContent: some View {
ScrollView {
VStack(spacing: 0) {
startNewCallRow
.padding(.top, 8)
recentSection
.padding(.top, 16)
}
.padding(.bottom, 100)
}
.scrollContentBackground(.hidden)
}
}
// MARK: - Filter Picker
private extension CallsView {
var filterPicker: some View {
HStack(spacing: 0) {
ForEach(CallFilter.allCases, id: \.self) { filter in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
selectedFilter = filter
}
} label: {
Text(filter.rawValue)
.font(.system(size: 15, weight: selectedFilter == filter ? .semibold : .regular))
.foregroundStyle(
selectedFilter == filter
? Color.white
: RosettaColors.Adaptive.textSecondary
)
.frame(width: 74)
.frame(height: 32)
.background {
if selectedFilter == filter {
Capsule()
.fill(Color.white.opacity(0.15))
}
}
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(4)
.glassCapsule()
}
}
// MARK: - Start New Call
private extension CallsView {
var startNewCallRow: some View {
VStack(spacing: 0) {
Button {} label: {
HStack(spacing: 12) {
Image(systemName: "phone.badge.plus")
.font(.system(size: 24))
.foregroundStyle(RosettaColors.primaryBlue)
.frame(width: 30)
Text("Start New Call")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.primaryBlue)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
Divider()
.background(Color.white.opacity(0.12))
.padding(.leading, 58)
}
}
}
// MARK: - Recent Calls Section
private extension CallsView {
var recentSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("RECENT CALLS")
.font(.system(size: 13, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.padding(.leading, 16)
VStack(spacing: 0) {
let calls = filteredCalls
ForEach(Array(calls.enumerated()), id: \.element.id) { index, call in
callRow(call)
if index < calls.count - 1 {
Divider()
.background(Color.white.opacity(0.12))
.padding(.leading, 88)
}
}
}
}
}
func callRow(_ call: CallEntry) -> some View {
HStack(spacing: 10) {
// Call direction icon (far left, Telegram-style)
Image(systemName: call.primaryType.directionIcon)
.font(.system(size: 13))
.foregroundStyle(call.primaryType.directionColor)
.frame(width: 18)
// Avatar reuse existing AvatarView component
AvatarView(
initials: call.initials,
colorIndex: call.colorIndex,
size: 44
)
// Name + call type subtitle
VStack(alignment: .leading, spacing: 2) {
Text(call.name)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(call.isMissed ? RosettaColors.error : .white)
.lineLimit(1)
Text(call.subtitleText)
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
Spacer()
// Date + info button
HStack(spacing: 10) {
Text(call.date)
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Button {} label: {
Image(systemName: "info.circle")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.primaryBlue)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(call.name), \(call.subtitleText), \(call.date)")
}
}
// MARK: - Preview (with mock data)
private struct CallsViewWithMockData: View {
var body: some View {
var view = CallsView()
view.recentCalls = [
CallEntry(name: "Alice Johnson", initials: "AJ", colorIndex: 0, types: [.outgoing], date: "01:50"),
CallEntry(name: "Bob Smith", initials: "BS", colorIndex: 1, types: [.incoming], date: "Sat"),
CallEntry(name: "Alice Johnson", initials: "AJ", colorIndex: 0, types: [.outgoing], duration: "12 sec", date: "28.02"),
CallEntry(name: "Carol White", initials: "CW", colorIndex: 2, types: [.outgoing], date: "27.02"),
CallEntry(name: "David Brown", initials: "DB", colorIndex: 3, types: [.outgoing, .incoming], date: "26.02"),
CallEntry(name: "Alice Johnson", initials: "AJ", colorIndex: 0, types: [.outgoing], duration: "1 min", date: "25.02"),
CallEntry(name: "Eve Davis", initials: "ED", colorIndex: 4, types: [.outgoing], duration: "2 sec", date: "24.02"),
CallEntry(name: "Frank Miller", initials: "FM", colorIndex: 5, types: [.missed], date: "24.02"),
CallEntry(name: "Carol White", initials: "CW", colorIndex: 2, types: [.incoming], date: "22.02"),
CallEntry(name: "Alice Johnson", initials: "AJ", colorIndex: 0, types: [.outgoing, .incoming], date: "21.02"),
]
return view
}
}
#Preview("Empty State") {
CallsView()
.preferredColorScheme(.dark)
}
#Preview("With Calls") {
CallsViewWithMockData()
.preferredColorScheme(.dark)
}

View File

@@ -45,6 +45,7 @@ struct ChatDetailView: View {
private var subtitleText: String { private var subtitleText: String {
if route.isSavedMessages { return "" } if route.isSavedMessages { return "" }
if ProtocolManager.shared.connectionState != .authenticated { return "connecting..." }
if isTyping { return "typing..." } if isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" } if let dialog, dialog.isOnline { return "online" }
return "offline" return "offline"
@@ -168,6 +169,7 @@ private extension ChatDetailView {
} }
} }
if !subtitleText.isEmpty {
Text(subtitleText) Text(subtitleText)
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundStyle( .foregroundStyle(
@@ -177,6 +179,7 @@ private extension ChatDetailView {
) )
.lineLimit(1) .lineLimit(1)
} }
}
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(height: 44) .frame(height: 44)
.background { .background {
@@ -218,6 +221,7 @@ private extension ChatDetailView {
} }
} }
if !subtitleText.isEmpty {
Text(subtitleText) Text(subtitleText)
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundStyle( .foregroundStyle(
@@ -227,6 +231,7 @@ private extension ChatDetailView {
) )
.lineLimit(1) .lineLimit(1)
} }
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(height: 44) .frame(height: 44)
.background { .background {

View File

@@ -262,9 +262,7 @@ private extension ChatListView {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
HStack(spacing: 4) { HStack(spacing: 4) {
ToolbarStoriesAvatar() ToolbarStoriesAvatar()
Text("Chats") ToolbarTitleView()
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
} }
} }
@@ -302,9 +300,7 @@ private extension ChatListView {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
HStack(spacing: 4) { HStack(spacing: 4) {
ToolbarStoriesAvatar() ToolbarStoriesAvatar()
Text("Chats") ToolbarTitleView()
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
} }
} }
@@ -355,6 +351,30 @@ private struct ChatListToolbarBackgroundModifier: ViewModifier {
} }
} }
// MARK: - Toolbar Title (observation-isolated)
/// Reads `ProtocolManager.shared.connectionState` in its own observation scope.
/// Connection state changes during handshake (4+ rapid transitions) are absorbed here,
/// not cascaded to the parent ChatListView / NavigationStack.
private struct ToolbarTitleView: View {
var body: some View {
let state = ProtocolManager.shared.connectionState
let title: String = switch state {
case .disconnected: "Connecting..."
case .connecting: "Connecting..."
case .connected: "Connected"
case .handshaking: "Authenticating..."
case .deviceVerificationRequired: "Device Verification..."
case .authenticated: "Chats"
}
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.25), value: state)
}
}
// MARK: - Toolbar Stories Avatar (observation-isolated) // MARK: - Toolbar Stories Avatar (observation-isolated)
/// Reads `AccountManager` and `SessionManager` in its own observation scope. /// Reads `AccountManager` and `SessionManager` in its own observation scope.
@@ -470,6 +490,13 @@ private struct ChatListDialogContent: View {
.listRowSeparatorTint(RosettaColors.Adaptive.divider) .listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 } .alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
.swipeActions(edge: .trailing, allowsFullSwipe: false) { .swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
withAnimation { viewModel.deleteDialog(dialog) }
} label: {
Label("Delete", systemImage: "trash")
}
if !dialog.isSavedMessages {
Button { Button {
viewModel.toggleMute(dialog) viewModel.toggleMute(dialog)
} label: { } label: {
@@ -480,13 +507,8 @@ private struct ChatListDialogContent: View {
} }
.tint(dialog.isMuted ? .green : .indigo) .tint(dialog.isMuted ? .green : .indigo)
} }
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
viewModel.markAsRead(dialog)
} label: {
Label("Read", systemImage: "envelope.open")
} }
.tint(RosettaColors.figmaBlue) .swipeActions(edge: .leading, allowsFullSwipe: true) {
Button { Button {
viewModel.togglePin(dialog) viewModel.togglePin(dialog)
} label: { } label: {

View File

@@ -6,7 +6,7 @@ struct MainTabView: View {
@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 isChatListDetailPresented = false @State private var isChatListDetailPresented = false
@State private var isSearchDetailPresented = false @State private var isSettingsEditPresented = false
/// All tabs are pre-activated so that switching only changes the offset, /// All tabs are pre-activated so that switching only changes the offset,
/// not the view structure. Creating a NavigationStack mid-animation causes /// not the view structure. Creating a NavigationStack mid-animation causes
/// "Update NavigationRequestObserver tried to update multiple times per frame" freeze. /// "Update NavigationRequestObserver tried to update multiple times per frame" freeze.
@@ -39,17 +39,17 @@ struct MainTabView: View {
.tag(RosettaTab.chats) .tag(RosettaTab.chats)
.badge(chatUnreadCount) .badge(chatUnreadCount)
SettingsView(onLogout: onLogout) CallsView()
.tabItem {
Label(RosettaTab.calls.label, systemImage: RosettaTab.calls.icon)
}
.tag(RosettaTab.calls)
SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented)
.tabItem { .tabItem {
Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon) Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon)
} }
.tag(RosettaTab.settings) .tag(RosettaTab.settings)
SearchView(isDetailPresented: $isSearchDetailPresented)
.tabItem {
Label(RosettaTab.search.label, systemImage: RosettaTab.search.icon)
}
.tag(RosettaTab.search)
} }
.tint(RosettaColors.primaryBlue) .tint(RosettaColors.primaryBlue)
} }
@@ -66,7 +66,7 @@ struct MainTabView: View {
} }
.ignoresSafeArea() .ignoresSafeArea()
if !isChatSearchActive && !isAnyChatDetailPresented { if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented {
RosettaTabBar( RosettaTabBar(
selectedTab: selectedTab, selectedTab: selectedTab,
onTabSelected: { tab in onTabSelected: { tab in
@@ -133,10 +133,10 @@ struct MainTabView: View {
isSearchActive: $isChatSearchActive, isSearchActive: $isChatSearchActive,
isDetailPresented: $isChatListDetailPresented isDetailPresented: $isChatListDetailPresented
) )
case .calls:
CallsView()
case .settings: case .settings:
SettingsView(onLogout: onLogout) SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented)
case .search:
SearchView(isDetailPresented: $isSearchDetailPresented)
} }
} else { } else {
RosettaColors.Adaptive.background RosettaColors.Adaptive.background
@@ -144,7 +144,7 @@ struct MainTabView: View {
} }
private var isAnyChatDetailPresented: Bool { private var isAnyChatDetailPresented: Bool {
isChatListDetailPresented || isSearchDetailPresented isChatListDetailPresented
} }
private var tabBadges: [TabBadge] { private var tabBadges: [TabBadge] {

View File

@@ -0,0 +1,197 @@
import PhotosUI
import SwiftUI
/// Embedded profile editing content (no NavigationStack lives inside SettingsView's).
/// Matches Telegram's edit screen: avatar + photo picker, name fields,
/// helper texts, "Add Another Account", and "Log Out".
struct ProfileEditView: View {
@Binding var displayName: String
@Binding var username: String
let publicKey: String
var onLogout: () -> Void
@State private var selectedPhotoItem: PhotosPickerItem?
@State private var selectedPhoto: UIImage?
private var initials: String {
RosettaColors.initials(name: displayName, publicKey: publicKey)
}
private var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey)
}
var body: some View {
VStack(spacing: 0) {
avatarSection
.padding(.bottom, 24)
nameSection
helperText("Enter your name and add an optional profile photo.")
.padding(.top, 8)
.padding(.bottom, 24)
addAccountSection
helperText("You can connect multiple accounts with different phone numbers.")
.padding(.top, 8)
.padding(.bottom, 24)
logoutSection
}
.padding(.horizontal, 16)
.padding(.top, 24)
.padding(.bottom, 100)
}
}
// MARK: - Avatar Section
private extension ProfileEditView {
var avatarSection: some View {
VStack(spacing: 12) {
if let selectedPhoto {
Image(uiImage: selectedPhoto)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(Circle())
} else {
AvatarView(
initials: initials,
colorIndex: avatarColorIndex,
size: 80,
isSavedMessages: false
)
}
PhotosPicker(selection: $selectedPhotoItem, matching: .images) {
Text("Set New Photo")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(RosettaColors.primaryBlue)
}
.buttonStyle(.plain)
.onChange(of: selectedPhotoItem) { _, item in
Task {
if let data = try? await item?.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
selectedPhoto = image
}
}
}
}
}
}
// MARK: - Name Section
private extension ProfileEditView {
var nameSection: some View {
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
VStack(spacing: 0) {
HStack {
TextField("First Name", text: $displayName)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
if !displayName.isEmpty {
Button { displayName = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.tertiaryText)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.frame(height: 52)
Divider()
.background(RosettaColors.Adaptive.divider)
.padding(.leading, 16)
HStack {
TextField("Username", text: $username)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !username.isEmpty {
Button { username = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.tertiaryText)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.frame(height: 52)
}
}
}
}
// MARK: - Add Account & Logout
private extension ProfileEditView {
var addAccountSection: some View {
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
Button {} label: {
HStack {
Spacer()
Text("Add Another Account")
.font(.system(size: 17))
.foregroundStyle(RosettaColors.primaryBlue)
Spacer()
}
.frame(height: 52)
}
.buttonStyle(.plain)
}
}
var logoutSection: some View {
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
Button(action: onLogout) {
HStack {
Spacer()
Text("Log Out")
.font(.system(size: 17))
.foregroundStyle(RosettaColors.error)
Spacer()
}
.frame(height: 52)
}
}
}
func helperText(_ text: String) -> some View {
Text(text)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
}
}
// MARK: - Preview
#Preview {
NavigationStack {
ScrollView {
ProfileEditView(
displayName: .constant("Gaidar"),
username: .constant("GaidarTheDev"),
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec",
onLogout: {}
)
}
.background(RosettaColors.Adaptive.background)
}
.preferredColorScheme(.dark)
}

View File

@@ -1,46 +1,41 @@
import SwiftUI import SwiftUI
/// Settings / Profile screen with Telegram iOS-style grouped glass cards. /// Settings screen with in-place profile editing transition.
/// Avatar stays in place, content fades between settings and edit modes,
/// tab bar slides down when editing.
struct SettingsView: View { struct SettingsView: View {
var onLogout: (() -> Void)? var onLogout: (() -> Void)?
@Binding var isEditingProfile: Bool
@StateObject 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 // Edit mode field state initialized when entering edit mode
@State private var editDisplayName = ""
@State private var editUsername = ""
var body: some View { var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🟢 SettingsView.body #\(Self._bodyCount)")
NavigationStack { NavigationStack {
ScrollView { ScrollView(showsIndicators: false) {
VStack(spacing: 16) { if isEditingProfile {
profileHeader ProfileEditView(
accountSection displayName: $editDisplayName,
generalSection username: $editUsername,
dangerSection publicKey: viewModel.publicKey,
onLogout: { showLogoutConfirmation = true }
)
.transition(.opacity)
} else {
settingsContent
.transition(.opacity)
} }
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 100)
} }
.background(RosettaColors.Adaptive.background) .background(RosettaColors.Adaptive.background)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar { toolbarContent }
ToolbarItem(placement: .principal) { .toolbarBackground(.hidden, for: .navigationBar)
Text("Settings")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Edit") {}
.font(.system(size: 17))
.foregroundStyle(RosettaColors.primaryBlue)
}
}
.toolbarBackground(.visible, for: .navigationBar)
.applyGlassNavBar()
.task { viewModel.refresh() } .task { viewModel.refresh() }
.alert("Log Out", isPresented: $showLogoutConfirmation) { .alert("Log Out", isPresented: $showLogoutConfirmation) {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
@@ -51,6 +46,9 @@ struct SettingsView: View {
} message: { } message: {
Text("Are you sure you want to log out?") Text("Are you sure you want to log out?")
} }
.onChange(of: isEditingProfile) { _, isEditing in
if !isEditing { viewModel.refresh() }
}
} }
.overlay(alignment: .top) { .overlay(alignment: .top) {
if showCopiedToast { if showCopiedToast {
@@ -60,6 +58,121 @@ struct SettingsView: View {
} }
} }
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
if isEditingProfile {
Button {
withAnimation(.easeInOut(duration: 0.3)) {
isEditingProfile = false
}
} label: {
Text("Cancel")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(height: 44)
.padding(.horizontal, 10)
}
.buttonStyle(.plain)
.glassCapsule()
} else {
Button {} label: {
Image(systemName: "qrcode")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.glassCircle()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if isEditingProfile {
Button {
saveProfile()
} label: {
Text("Done")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(
hasProfileChanges
? RosettaColors.Adaptive.text
: RosettaColors.Adaptive.text.opacity(0.4)
)
.frame(height: 44)
.padding(.horizontal, 10)
}
.buttonStyle(.plain)
.glassCapsule()
} else {
Button {
editDisplayName = viewModel.displayName
editUsername = viewModel.username
withAnimation(.easeInOut(duration: 0.3)) {
isEditingProfile = true
}
} label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(height: 44)
.padding(.horizontal, 10)
}
.buttonStyle(.plain)
.glassCapsule()
}
}
}
// MARK: - Profile Save
private var hasProfileChanges: Bool {
editDisplayName != viewModel.displayName || editUsername != viewModel.username
}
private func saveProfile() {
let trimmedName = editDisplayName.trimmingCharacters(in: .whitespaces)
let trimmedUsername = editUsername.trimmingCharacters(in: .whitespaces)
if hasProfileChanges {
AccountManager.shared.updateProfile(
displayName: trimmedName,
username: trimmedUsername
)
SessionManager.shared.updateDisplayNameAndUsername(
displayName: trimmedName,
username: trimmedUsername
)
if let hash = SessionManager.shared.privateKeyHash {
var packet = PacketUserInfo()
packet.username = trimmedUsername
packet.title = trimmedName
packet.privateKey = hash
ProtocolManager.shared.sendPacket(packet)
}
}
withAnimation(.easeInOut(duration: 0.3)) {
isEditingProfile = false
}
}
// MARK: - Settings Content
private var settingsContent: some View {
VStack(spacing: 16) {
profileHeader
accountSection
settingsSection
dangerSection
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 100)
}
// MARK: - Profile Header // MARK: - Profile Header
private var profileHeader: some View { private var profileHeader: some View {
@@ -72,29 +185,21 @@ struct SettingsView: View {
) )
VStack(spacing: 4) { VStack(spacing: 4) {
Text(viewModel.displayName.isEmpty ? "Set Display Name" : viewModel.displayName) HStack(spacing: 4) {
Text(viewModel.headerName)
.font(.system(size: 22, weight: .bold)) .font(.system(size: 22, weight: .bold))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
VerifiedBadge(verified: viewModel.verified, size: 18)
}
if !viewModel.username.isEmpty { if !viewModel.username.isEmpty {
Text("@\(viewModel.username)") Text("@\(viewModel.username)")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(RosettaColors.secondaryText) .foregroundStyle(RosettaColors.secondaryText)
} }
// Connection status
HStack(spacing: 6) {
Circle()
.fill(viewModel.isConnected ? RosettaColors.online : RosettaColors.tertiaryText)
.frame(width: 8, height: 8)
Text(viewModel.connectionStatus)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.tertiaryText)
}
.padding(.top, 4)
} }
// Public key
Button { Button {
viewModel.copyPublicKey() viewModel.copyPublicKey()
withAnimation(.easeInOut(duration: 0.25)) { showCopiedToast = true } withAnimation(.easeInOut(duration: 0.25)) { showCopiedToast = true }
@@ -119,31 +224,29 @@ struct SettingsView: View {
// MARK: - Account Section // MARK: - Account Section
private var accountSection: some View { private var accountSection: some View {
GlassCard(cornerRadius: 12, fillOpacity: 0.08) { GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
VStack(spacing: 0) { VStack(spacing: 0) {
settingsRow(icon: "person.fill", title: "My Profile", color: .red) {} settingsRow(icon: "person.fill", title: "My Profile", color: .red) {}
sectionDivider sectionDivider
settingsRow(icon: "bookmark.fill", title: "Saved Messages", color: .purple) {} settingsRow(icon: "bookmark.fill", title: "Saved Messages", color: RosettaColors.primaryBlue) {}
sectionDivider sectionDivider
settingsRow(icon: "desktopcomputer", title: "Devices", color: .orange) {} settingsRow(icon: "desktopcomputer", title: "Devices", color: .orange) {}
sectionDivider
settingsRow(icon: "folder.fill", title: "Chat Folders", color: .blue) {}
} }
} }
} }
// MARK: - General Section // MARK: - Settings Section
private var generalSection: some View { private var settingsSection: some View {
GlassCard(cornerRadius: 12, fillOpacity: 0.08) { GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
VStack(spacing: 0) { VStack(spacing: 0) {
settingsRow(icon: "bell.fill", title: "Notifications", color: .red) {} settingsRow(icon: "paintbrush.fill", title: "Appearance", color: RosettaColors.primaryBlue) {}
sectionDivider sectionDivider
settingsRow(icon: "lock.fill", title: "Privacy and Security", color: .gray) {} settingsRow(icon: "lock.fill", title: "Privacy and Security", color: .gray) {}
sectionDivider sectionDivider
settingsRow(icon: "paintbrush.fill", title: "Appearance", color: .blue) {} settingsRow(icon: "bell.fill", title: "Notifications", color: .red) {}
sectionDivider sectionDivider
settingsRow(icon: "globe", title: "Language", color: .purple) {} settingsRow(icon: "ladybug.fill", title: "Crash Logs", color: .orange) {}
} }
} }
} }
@@ -151,7 +254,7 @@ struct SettingsView: View {
// MARK: - Danger Section // MARK: - Danger Section
private var dangerSection: some View { private var dangerSection: some View {
GlassCard(cornerRadius: 12, fillOpacity: 0.08) { GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
Button { Button {
showLogoutConfirmation = true showLogoutConfirmation = true
} label: { } label: {
@@ -162,47 +265,64 @@ struct SettingsView: View {
.foregroundStyle(RosettaColors.error) .foregroundStyle(RosettaColors.error)
Spacer() Spacer()
} }
.padding(.vertical, 14) .frame(height: 52)
} }
} }
} }
// MARK: - Helpers // MARK: - Helpers
/// Figma row: 52pt height, 30×30 rounded icon (r7), 23pt symbol, Medium 17pt title.
private func settingsRow( private func settingsRow(
icon: String, icon: String,
title: String, title: String,
color: Color, color: Color,
detail: String? = nil,
showChevron: Bool = true,
action: @escaping () -> Void action: @escaping () -> Void
) -> some View { ) -> some View {
Button(action: action) { Button(action: action) {
HStack(spacing: 14) { HStack(spacing: 0) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 16)) .font(.system(size: 21))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(.white)
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
.background(color) .background(color)
.clipShape(RoundedRectangle(cornerRadius: 7)) .clipShape(RoundedRectangle(cornerRadius: 7))
.padding(.trailing, 16)
Text(title) Text(title)
.font(.system(size: 17)) .font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
Spacer() Spacer(minLength: 8)
if let detail {
Text(detail)
.font(.system(size: 17))
.tracking(-0.43)
.foregroundStyle(RosettaColors.secondaryText)
}
if showChevron {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.tertiaryText) .foregroundStyle(RosettaColors.tertiaryText)
.frame(width: 8)
.padding(.leading, detail != nil ? 16 : 0)
}
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .frame(height: 52)
} }
} }
private var sectionDivider: some View { private var sectionDivider: some View {
Divider() Divider()
.background(RosettaColors.Adaptive.divider) .background(RosettaColors.Adaptive.divider)
.padding(.leading, 60) .padding(.leading, 62)
} }
private func formatPublicKey(_ key: String) -> String { private func formatPublicKey(_ key: String) -> String {

View File

@@ -21,6 +21,14 @@ final class SettingsViewModel: ObservableObject {
@Published private(set) var publicKey: String = "" @Published private(set) var publicKey: String = ""
@Published private(set) var connectionStatus: String = "Disconnected" @Published private(set) var connectionStatus: String = "Disconnected"
@Published private(set) var isConnected: Bool = false @Published private(set) var isConnected: Bool = false
@Published private(set) var deviceCount: Int = 0
/// Display name for the header. Falls back to first 7 chars of public key.
var headerName: String {
if !displayName.isEmpty { return displayName }
if publicKey.count >= 7 { return String(publicKey.prefix(7)) }
return publicKey
}
var initials: String { var initials: String {
RosettaColors.initials(name: displayName, publicKey: publicKey) RosettaColors.initials(name: displayName, publicKey: publicKey)
@@ -30,6 +38,20 @@ final class SettingsViewModel: ObservableObject {
RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey) RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey)
} }
/// Own account verified level.
/// Shows badge only for Rosetta administration accounts (level 2).
var verified: Int {
let name = displayName
let user = username
let key = publicKey
if name.caseInsensitiveCompare("Rosetta") == .orderedSame
|| user.caseInsensitiveCompare("rosetta") == .orderedSame
|| SystemAccounts.isSystemAccount(key) {
return 2
}
return 0
}
/// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`. /// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`.
func refresh() { func refresh() {
let session = SessionManager.shared let session = SessionManager.shared

File diff suppressed because one or more lines are too long

View File

@@ -113,6 +113,7 @@ struct RosettaApp: App {
@AppStorage("isLoggedIn") private var isLoggedIn = false @AppStorage("isLoggedIn") private var isLoggedIn = false
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false @AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
@State private var appState: AppState? @State private var appState: AppState?
@State private var transitionOverlay: Bool = false
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
@@ -122,8 +123,15 @@ struct RosettaApp: App {
if let appState { if let appState {
rootView(for: appState) rootView(for: appState)
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
} }
// Fade-through-black overlay for smooth screen transitions.
// Avoids UIKit-hosted Lottie views fighting SwiftUI opacity transitions.
Color.black
.ignoresSafeArea()
.opacity(transitionOverlay ? 1 : 0)
.allowsHitTesting(transitionOverlay)
.animation(.easeInOut(duration: 0.12), value: transitionOverlay)
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.onAppear { .onAppear {
@@ -142,55 +150,55 @@ struct RosettaApp: App {
switch state { switch state {
case .onboarding: case .onboarding:
OnboardingView { OnboardingView {
withAnimation(.easeInOut(duration: 0.55)) {
hasCompletedOnboarding = true hasCompletedOnboarding = true
appState = .auth fadeTransition(to: .auth)
}
} }
case .auth: case .auth:
AuthCoordinator( AuthCoordinator(
onAuthComplete: { onAuthComplete: {
withAnimation(.easeInOut(duration: 0.55)) {
isLoggedIn = true isLoggedIn = true
appState = .main fadeTransition(to: .main)
}
}, },
onBackToUnlock: AccountManager.shared.hasAccount ? { onBackToUnlock: AccountManager.shared.hasAccount ? {
// Go back to unlock screen if an account exists fadeTransition(to: .unlock)
withAnimation(.easeInOut(duration: 0.55)) {
appState = .unlock
}
} : nil } : nil
) )
case .unlock: case .unlock:
UnlockView( UnlockView(
onUnlocked: { onUnlocked: {
withAnimation(.easeInOut(duration: 0.55)) {
isLoggedIn = true isLoggedIn = true
appState = .main fadeTransition(to: .main)
}
}, },
onCreateNewAccount: { onCreateNewAccount: {
// Go to auth flow (Welcome screen with back button) // Go to auth flow (Welcome screen with back button)
// Does NOT delete the old account Android keeps multiple accounts // Does NOT delete the old account Android keeps multiple accounts
withAnimation(.easeInOut(duration: 0.55)) { fadeTransition(to: .auth)
appState = .auth
}
} }
) )
case .main: case .main:
MainTabView(onLogout: { MainTabView(onLogout: {
withAnimation(.easeInOut(duration: 0.55)) {
isLoggedIn = false isLoggedIn = false
appState = .unlock fadeTransition(to: .unlock)
}
}) })
} }
} }
/// Fade-through-black transition: overlay fades in swap content overlay fades out.
/// Avoids UIKit-hosted views (Lottie, UIPageViewController) fighting SwiftUI transitions.
private func fadeTransition(to newState: AppState) {
guard !transitionOverlay else { return }
transitionOverlay = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 140_000_000) // wait for overlay fade-in
appState = newState
try? await Task.sleep(nanoseconds: 30_000_000) // brief settle
transitionOverlay = false
}
}
private func initialState() -> AppState { private func initialState() -> AppState {
if AccountManager.shared.hasAccount { if AccountManager.shared.hasAccount {
return .unlock return .unlock