Исправления UI: центрирование Saved Messages, размеры тулбара звонков, отображение "Connecting...", локальная отправка в Saved Messages
This commit is contained in:
@@ -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 = "";
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
362
Rosetta/Features/Calls/CallsView.swift
Normal file
362
Rosetta/Features/Calls/CallsView.swift
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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] {
|
||||||
|
|||||||
197
Rosetta/Features/Settings/ProfileEditView.swift
Normal file
197
Rosetta/Features/Settings/ProfileEditView.swift
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1
Rosetta/Resources/Lottie/phone_duck.json
Normal file
1
Rosetta/Resources/Lottie/phone_duck.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user