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

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

View File

@@ -272,7 +272,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_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 = "";

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

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

View File

@@ -45,6 +45,7 @@ struct ChatDetailView: View {
private var subtitleText: String {
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 {

View File

@@ -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: {

View File

@@ -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] {

View File

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

View File

@@ -1,46 +1,41 @@
import SwiftUI
/// 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 {

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -113,6 +113,7 @@ struct RosettaApp: App {
@AppStorage("isLoggedIn") private var isLoggedIn = false
@AppStorage("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