Исправления UI: центрирование Saved Messages, размеры тулбара звонков, отображение "Connecting...", локальная отправка в Saved Messages
This commit is contained in:
@@ -272,7 +272,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -288,7 +288,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.8;
|
||||
MARKETING_VERSION = 1.0.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -311,7 +311,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -327,7 +327,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.8;
|
||||
MARKETING_VERSION = 1.0.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
/// 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 {
|
||||
static let packetId = 0x01
|
||||
|
||||
var username: String = ""
|
||||
var avatar: String = ""
|
||||
var title: String = ""
|
||||
var privateKey: String = ""
|
||||
|
||||
func write(to stream: Stream) {
|
||||
// Send: username, avatar, title, privateKey (match TypeScript server)
|
||||
stream.writeString(username)
|
||||
stream.writeString(avatar)
|
||||
stream.writeString(title)
|
||||
stream.writeString(privateKey)
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
// Receive: username, avatar, title, privateKey (match TypeScript server)
|
||||
username = stream.readString()
|
||||
avatar = stream.readString()
|
||||
title = stream.readString()
|
||||
privateKey = stream.readString()
|
||||
}
|
||||
|
||||
@@ -152,6 +152,13 @@ final class SessionManager {
|
||||
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
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
@@ -183,6 +190,12 @@ final class SessionManager {
|
||||
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.
|
||||
func endSession() {
|
||||
ProtocolManager.shared.disconnect()
|
||||
@@ -336,7 +349,6 @@ final class SessionManager {
|
||||
if !name.isEmpty || !uname.isEmpty {
|
||||
var userInfoPacket = PacketUserInfo()
|
||||
userInfoPacket.username = uname
|
||||
userInfoPacket.avatar = ""
|
||||
userInfoPacket.title = name
|
||||
userInfoPacket.privateKey = hash
|
||||
ProtocolManager.shared.sendPacket(userInfoPacket)
|
||||
|
||||
@@ -71,7 +71,7 @@ struct AvatarView: View {
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if isOnline {
|
||||
if isOnline && !isSavedMessages {
|
||||
Circle()
|
||||
.fill(RosettaColors.primaryBlue)
|
||||
.frame(width: badgeSize, height: badgeSize)
|
||||
|
||||
@@ -5,32 +5,32 @@ import UIKit
|
||||
|
||||
enum RosettaTab: CaseIterable, Sendable {
|
||||
case chats
|
||||
case calls
|
||||
case settings
|
||||
case search
|
||||
|
||||
static let interactionOrder: [RosettaTab] = [.chats, .settings, .search]
|
||||
static let interactionOrder: [RosettaTab] = [.chats, .calls, .settings]
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .chats: return "Chats"
|
||||
case .calls: return "Calls"
|
||||
case .settings: return "Settings"
|
||||
case .search: return "Search"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .chats: return "bubble.left.and.bubble.right"
|
||||
case .calls: return "phone"
|
||||
case .settings: return "gearshape"
|
||||
case .search: return "magnifyingglass"
|
||||
}
|
||||
}
|
||||
|
||||
var selectedIcon: String {
|
||||
switch self {
|
||||
case .chats: return "bubble.left.and.bubble.right.fill"
|
||||
case .calls: return "phone.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 navigationDirection: NavigationDirection = .forward
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
@State private var fadeOverlay: Bool = false
|
||||
|
||||
private var canSwipeBack: Bool {
|
||||
currentScreen != .welcome
|
||||
@@ -58,6 +59,14 @@ struct AuthCoordinator: View {
|
||||
.id(currentScreen)
|
||||
.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) {
|
||||
if canSwipeBack {
|
||||
Color.clear
|
||||
@@ -158,15 +167,20 @@ private extension AuthCoordinator {
|
||||
|
||||
private extension AuthCoordinator {
|
||||
func navigateTo(_ screen: AuthScreen) {
|
||||
guard !fadeOverlay else { return }
|
||||
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
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
fadeOverlay = false
|
||||
}
|
||||
}
|
||||
|
||||
func navigateBack(to screen: AuthScreen) {
|
||||
navigationDirection = .backward
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.95)) {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.9)) {
|
||||
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 {
|
||||
if route.isSavedMessages { return "" }
|
||||
if ProtocolManager.shared.connectionState != .authenticated { return "connecting..." }
|
||||
if isTyping { return "typing..." }
|
||||
if let dialog, dialog.isOnline { return "online" }
|
||||
return "offline"
|
||||
@@ -168,6 +169,7 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
if !subtitleText.isEmpty {
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(
|
||||
@@ -177,6 +179,7 @@ private extension ChatDetailView {
|
||||
)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
@@ -218,6 +221,7 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
if !subtitleText.isEmpty {
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(
|
||||
@@ -227,6 +231,7 @@ private extension ChatDetailView {
|
||||
)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
|
||||
@@ -262,9 +262,7 @@ private extension ChatListView {
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
ToolbarTitleView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,9 +300,7 @@ private extension ChatListView {
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
ToolbarTitleView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
/// Reads `AccountManager` and `SessionManager` in its own observation scope.
|
||||
@@ -470,6 +490,13 @@ private struct ChatListDialogContent: View {
|
||||
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
withAnimation { viewModel.deleteDialog(dialog) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
if !dialog.isSavedMessages {
|
||||
Button {
|
||||
viewModel.toggleMute(dialog)
|
||||
} label: {
|
||||
@@ -480,13 +507,8 @@ private struct ChatListDialogContent: View {
|
||||
}
|
||||
.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 {
|
||||
viewModel.togglePin(dialog)
|
||||
} label: {
|
||||
|
||||
@@ -6,7 +6,7 @@ struct MainTabView: View {
|
||||
@State private var selectedTab: RosettaTab = .chats
|
||||
@State private var isChatSearchActive = 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,
|
||||
/// not the view structure. Creating a NavigationStack mid-animation causes
|
||||
/// "Update NavigationRequestObserver tried to update multiple times per frame" → freeze.
|
||||
@@ -39,17 +39,17 @@ struct MainTabView: View {
|
||||
.tag(RosettaTab.chats)
|
||||
.badge(chatUnreadCount)
|
||||
|
||||
SettingsView(onLogout: onLogout)
|
||||
CallsView()
|
||||
.tabItem {
|
||||
Label(RosettaTab.calls.label, systemImage: RosettaTab.calls.icon)
|
||||
}
|
||||
.tag(RosettaTab.calls)
|
||||
|
||||
SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented)
|
||||
.tabItem {
|
||||
Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon)
|
||||
}
|
||||
.tag(RosettaTab.settings)
|
||||
|
||||
SearchView(isDetailPresented: $isSearchDetailPresented)
|
||||
.tabItem {
|
||||
Label(RosettaTab.search.label, systemImage: RosettaTab.search.icon)
|
||||
}
|
||||
.tag(RosettaTab.search)
|
||||
}
|
||||
.tint(RosettaColors.primaryBlue)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ struct MainTabView: View {
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
if !isChatSearchActive && !isAnyChatDetailPresented {
|
||||
if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented {
|
||||
RosettaTabBar(
|
||||
selectedTab: selectedTab,
|
||||
onTabSelected: { tab in
|
||||
@@ -133,10 +133,10 @@ struct MainTabView: View {
|
||||
isSearchActive: $isChatSearchActive,
|
||||
isDetailPresented: $isChatListDetailPresented
|
||||
)
|
||||
case .calls:
|
||||
CallsView()
|
||||
case .settings:
|
||||
SettingsView(onLogout: onLogout)
|
||||
case .search:
|
||||
SearchView(isDetailPresented: $isSearchDetailPresented)
|
||||
SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented)
|
||||
}
|
||||
} else {
|
||||
RosettaColors.Adaptive.background
|
||||
@@ -144,7 +144,7 @@ struct MainTabView: View {
|
||||
}
|
||||
|
||||
private var isAnyChatDetailPresented: Bool {
|
||||
isChatListDetailPresented || isSearchDetailPresented
|
||||
isChatListDetailPresented
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/// 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 {
|
||||
var onLogout: (() -> Void)?
|
||||
@Binding var isEditingProfile: Bool
|
||||
|
||||
@StateObject private var viewModel = SettingsViewModel()
|
||||
@State private var showCopiedToast = 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 {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟢 SettingsView.body #\(Self._bodyCount)")
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
profileHeader
|
||||
accountSection
|
||||
generalSection
|
||||
dangerSection
|
||||
ScrollView(showsIndicators: false) {
|
||||
if isEditingProfile {
|
||||
ProfileEditView(
|
||||
displayName: $editDisplayName,
|
||||
username: $editUsername,
|
||||
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)
|
||||
.scrollContentBackground(.hidden)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
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()
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.task { viewModel.refresh() }
|
||||
.alert("Log Out", isPresented: $showLogoutConfirmation) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
@@ -51,6 +46,9 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text("Are you sure you want to log out?")
|
||||
}
|
||||
.onChange(of: isEditingProfile) { _, isEditing in
|
||||
if !isEditing { viewModel.refresh() }
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
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
|
||||
|
||||
private var profileHeader: some View {
|
||||
@@ -72,29 +185,21 @@ struct SettingsView: View {
|
||||
)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(viewModel.displayName.isEmpty ? "Set Display Name" : viewModel.displayName)
|
||||
HStack(spacing: 4) {
|
||||
Text(viewModel.headerName)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
VerifiedBadge(verified: viewModel.verified, size: 18)
|
||||
}
|
||||
|
||||
if !viewModel.username.isEmpty {
|
||||
Text("@\(viewModel.username)")
|
||||
.font(.system(size: 15))
|
||||
.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 {
|
||||
viewModel.copyPublicKey()
|
||||
withAnimation(.easeInOut(duration: 0.25)) { showCopiedToast = true }
|
||||
@@ -119,31 +224,29 @@ struct SettingsView: View {
|
||||
// MARK: - Account Section
|
||||
|
||||
private var accountSection: some View {
|
||||
GlassCard(cornerRadius: 12, fillOpacity: 0.08) {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
VStack(spacing: 0) {
|
||||
settingsRow(icon: "person.fill", title: "My Profile", color: .red) {}
|
||||
sectionDivider
|
||||
settingsRow(icon: "bookmark.fill", title: "Saved Messages", color: .purple) {}
|
||||
settingsRow(icon: "bookmark.fill", title: "Saved Messages", color: RosettaColors.primaryBlue) {}
|
||||
sectionDivider
|
||||
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 {
|
||||
GlassCard(cornerRadius: 12, fillOpacity: 0.08) {
|
||||
private var settingsSection: some View {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
VStack(spacing: 0) {
|
||||
settingsRow(icon: "bell.fill", title: "Notifications", color: .red) {}
|
||||
settingsRow(icon: "paintbrush.fill", title: "Appearance", color: RosettaColors.primaryBlue) {}
|
||||
sectionDivider
|
||||
settingsRow(icon: "lock.fill", title: "Privacy and Security", color: .gray) {}
|
||||
sectionDivider
|
||||
settingsRow(icon: "paintbrush.fill", title: "Appearance", color: .blue) {}
|
||||
settingsRow(icon: "bell.fill", title: "Notifications", color: .red) {}
|
||||
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
|
||||
|
||||
private var dangerSection: some View {
|
||||
GlassCard(cornerRadius: 12, fillOpacity: 0.08) {
|
||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||
Button {
|
||||
showLogoutConfirmation = true
|
||||
} label: {
|
||||
@@ -162,47 +265,64 @@ struct SettingsView: View {
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.frame(height: 52)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Figma row: 52pt height, 30×30 rounded icon (r7), 23pt symbol, Medium 17pt title.
|
||||
private func settingsRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
color: Color,
|
||||
detail: String? = nil,
|
||||
showChevron: Bool = true,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.font(.system(size: 21))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
.padding(.trailing, 16)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 17))
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.tracking(-0.43)
|
||||
.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")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.tertiaryText)
|
||||
.frame(width: 8)
|
||||
.padding(.leading, detail != nil ? 16 : 0)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(height: 52)
|
||||
}
|
||||
}
|
||||
|
||||
private var sectionDivider: some View {
|
||||
Divider()
|
||||
.background(RosettaColors.Adaptive.divider)
|
||||
.padding(.leading, 60)
|
||||
.padding(.leading, 62)
|
||||
}
|
||||
|
||||
private func formatPublicKey(_ key: String) -> String {
|
||||
|
||||
@@ -21,6 +21,14 @@ final class SettingsViewModel: ObservableObject {
|
||||
@Published private(set) var publicKey: String = ""
|
||||
@Published private(set) var connectionStatus: String = "Disconnected"
|
||||
@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 {
|
||||
RosettaColors.initials(name: displayName, publicKey: publicKey)
|
||||
@@ -30,6 +38,20 @@ final class SettingsViewModel: ObservableObject {
|
||||
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`.
|
||||
func refresh() {
|
||||
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("hasLaunchedBefore") private var hasLaunchedBefore = false
|
||||
@State private var appState: AppState?
|
||||
@State private var transitionOverlay: Bool = false
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@@ -122,8 +123,15 @@ struct RosettaApp: App {
|
||||
|
||||
if let 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)
|
||||
.onAppear {
|
||||
@@ -142,55 +150,55 @@ struct RosettaApp: App {
|
||||
switch state {
|
||||
case .onboarding:
|
||||
OnboardingView {
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
hasCompletedOnboarding = true
|
||||
appState = .auth
|
||||
}
|
||||
fadeTransition(to: .auth)
|
||||
}
|
||||
|
||||
case .auth:
|
||||
AuthCoordinator(
|
||||
onAuthComplete: {
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
isLoggedIn = true
|
||||
appState = .main
|
||||
}
|
||||
fadeTransition(to: .main)
|
||||
},
|
||||
onBackToUnlock: AccountManager.shared.hasAccount ? {
|
||||
// Go back to unlock screen if an account exists
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
appState = .unlock
|
||||
}
|
||||
fadeTransition(to: .unlock)
|
||||
} : nil
|
||||
)
|
||||
|
||||
case .unlock:
|
||||
UnlockView(
|
||||
onUnlocked: {
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
isLoggedIn = true
|
||||
appState = .main
|
||||
}
|
||||
fadeTransition(to: .main)
|
||||
},
|
||||
onCreateNewAccount: {
|
||||
// Go to auth flow (Welcome screen with back button)
|
||||
// Does NOT delete the old account — Android keeps multiple accounts
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
appState = .auth
|
||||
}
|
||||
fadeTransition(to: .auth)
|
||||
}
|
||||
)
|
||||
|
||||
case .main:
|
||||
MainTabView(onLogout: {
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
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 {
|
||||
if AccountManager.shared.hasAccount {
|
||||
return .unlock
|
||||
|
||||
Reference in New Issue
Block a user