1031 lines
38 KiB
Swift
1031 lines
38 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Settings Navigation
|
||
|
||
enum SettingsDestination: Hashable {
|
||
case updates
|
||
case safety
|
||
case backup
|
||
}
|
||
|
||
/// 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)?
|
||
var onAddAccount: ((AuthScreen) -> Void)?
|
||
@Binding var isEditingProfile: Bool
|
||
@Binding var isDetailPresented: Bool
|
||
|
||
@StateObject private var viewModel = SettingsViewModel()
|
||
@State private var navigationPath = NavigationPath()
|
||
@State private var showDeleteAccountConfirmation = false
|
||
@State private var showAddAccountSheet = false
|
||
|
||
// Biometric
|
||
@State private var isBiometricEnabled = false
|
||
@State private var showBiometricPasswordPrompt = false
|
||
@State private var biometricPassword = ""
|
||
@State private var biometricError: String?
|
||
|
||
// Avatar
|
||
@State private var avatarImage: UIImage?
|
||
/// Photo selected in ProfileEditView but not yet committed — saved on Done press.
|
||
@State private var pendingAvatarPhoto: UIImage?
|
||
|
||
// Edit mode field state — initialized when entering edit mode
|
||
@State private var editDisplayName = ""
|
||
@State private var editUsername = ""
|
||
@State private var displayNameError: String?
|
||
@State private var usernameError: String?
|
||
@State private var isSaving = false
|
||
|
||
var body: some View {
|
||
NavigationStack(path: $navigationPath) {
|
||
ScrollView(showsIndicators: false) {
|
||
if isEditingProfile {
|
||
ProfileEditView(
|
||
onAddAccount: onAddAccount,
|
||
displayName: $editDisplayName,
|
||
username: $editUsername,
|
||
publicKey: viewModel.publicKey,
|
||
displayNameError: $displayNameError,
|
||
usernameError: $usernameError,
|
||
pendingPhoto: $pendingAvatarPhoto
|
||
)
|
||
.transition(.opacity)
|
||
} else {
|
||
settingsContent
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.background(RosettaColors.Adaptive.background)
|
||
.scrollContentBackground(.hidden)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { toolbarContent }
|
||
.toolbarBackground(.hidden, for: .navigationBar)
|
||
.navigationDestination(for: SettingsDestination.self) { destination in
|
||
switch destination {
|
||
case .updates:
|
||
UpdatesView()
|
||
case .safety:
|
||
SafetyView(onLogout: onLogout)
|
||
case .backup:
|
||
BackupView()
|
||
}
|
||
}
|
||
.task {
|
||
viewModel.refresh()
|
||
refreshBiometricState()
|
||
avatarImage = AvatarRepository.shared.loadAvatar(publicKey: viewModel.publicKey)
|
||
}
|
||
.alert("Delete Account", isPresented: $showDeleteAccountConfirmation) {
|
||
Button("Cancel", role: .cancel) {}
|
||
Button("Delete Account", role: .destructive) {
|
||
let publicKey = SessionManager.shared.currentPublicKey
|
||
BiometricAuthManager.shared.clearAll(forAccount: publicKey)
|
||
AvatarRepository.shared.removeAvatar(publicKey: publicKey)
|
||
// Start fade overlay FIRST — covers the screen before
|
||
// deleteAccount() triggers setActiveAccount(next) which
|
||
// would cause MainTabView .id() recreation.
|
||
onLogout?()
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
|
||
DialogRepository.shared.reset(clearPersisted: true)
|
||
MessageRepository.shared.reset(clearPersisted: true)
|
||
let defaults = UserDefaults.standard
|
||
defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)")
|
||
defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)")
|
||
defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)")
|
||
SessionManager.shared.endSession()
|
||
try? AccountManager.shared.deleteAccount()
|
||
}
|
||
}
|
||
} message: {
|
||
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.")
|
||
}
|
||
.onChange(of: isEditingProfile) { _, isEditing in
|
||
if !isEditing {
|
||
viewModel.refresh()
|
||
refreshBiometricState()
|
||
avatarImage = AvatarRepository.shared.loadAvatar(publicKey: viewModel.publicKey)
|
||
}
|
||
}
|
||
.onAppear { refreshBiometricState() }
|
||
.onReceive(NotificationCenter.default.publisher(for: .profileDidUpdate)) { _ in
|
||
viewModel.refresh()
|
||
}
|
||
.alert(
|
||
"Enable \(BiometricAuthManager.shared.biometricName)",
|
||
isPresented: $showBiometricPasswordPrompt
|
||
) {
|
||
SecureField("Password", text: $biometricPassword)
|
||
Button("Cancel", role: .cancel) {
|
||
biometricPassword = ""
|
||
biometricError = nil
|
||
isBiometricEnabled = false
|
||
}
|
||
Button("Enable") { enableBiometric() }
|
||
} message: {
|
||
if let biometricError {
|
||
Text(biometricError)
|
||
} else {
|
||
Text("Enter your password to securely save it for \(BiometricAuthManager.shared.biometricName) unlock.")
|
||
}
|
||
}
|
||
.onChange(of: navigationPath.count) { _, newCount in
|
||
isDetailPresented = newCount > 0
|
||
}
|
||
.confirmationDialog(
|
||
"Create account",
|
||
isPresented: $showAddAccountSheet,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("Create New Account") {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||
onAddAccount?(.seedPhrase)
|
||
}
|
||
}
|
||
Button("Import Existing Account") {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||
onAddAccount?(.importSeed)
|
||
}
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: {
|
||
Text("You may create a new account or import an existing one.")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Toolbar
|
||
|
||
@ToolbarContentBuilder
|
||
private var toolbarContent: some ToolbarContent {
|
||
ToolbarItem(placement: .navigationBarLeading) {
|
||
if isEditingProfile {
|
||
Button {
|
||
pendingAvatarPhoto = nil
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
isEditingProfile = false
|
||
}
|
||
} label: {
|
||
Text("Cancel")
|
||
.font(.system(size: 17, weight: .medium))
|
||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||
.frame(height: 44)
|
||
.padding(.horizontal, 10)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.glassCapsule()
|
||
.disabled(isSaving)
|
||
} 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
|
||
displayNameError = nil
|
||
usernameError = nil
|
||
pendingAvatarPhoto = nil
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
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
|
||
|| pendingAvatarPhoto != nil
|
||
}
|
||
|
||
private func saveProfile() {
|
||
let trimmedName = editDisplayName.trimmingCharacters(in: .whitespaces)
|
||
let trimmedUsername = editUsername.trimmingCharacters(in: .whitespaces).lowercased()
|
||
|
||
// Validate before saving
|
||
if let error = ProfileValidator.validateDisplayName(trimmedName) {
|
||
displayNameError = error.errorDescription
|
||
return
|
||
}
|
||
if let error = ProfileValidator.validateUsername(trimmedUsername) {
|
||
usernameError = error.errorDescription
|
||
return
|
||
}
|
||
displayNameError = nil
|
||
usernameError = nil
|
||
|
||
guard hasProfileChanges else {
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
isEditingProfile = false
|
||
}
|
||
return
|
||
}
|
||
|
||
let hasTextChanges = trimmedName != viewModel.displayName
|
||
|| trimmedUsername != viewModel.username
|
||
|
||
// Avatar-only change — save locally, no server round-trip needed
|
||
if !hasTextChanges {
|
||
commitPendingAvatar()
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
isEditingProfile = false
|
||
}
|
||
return
|
||
}
|
||
|
||
guard !isSaving else { return }
|
||
isSaving = true
|
||
|
||
// Register one-shot result handler (Android parity: waitPacket(0x02))
|
||
let handlerId = ProtocolManager.shared.addResultHandler { result in
|
||
Task { @MainActor in
|
||
guard isSaving else { return }
|
||
isSaving = false
|
||
|
||
if result.resultCode == ResultCode.usernameTaken.rawValue {
|
||
usernameError = "This username is already taken"
|
||
} else {
|
||
// Server confirmed OR unknown code — save locally.
|
||
// PacketResult has no request ID, so we can't guarantee this
|
||
// response belongs to our PacketUserInfo. Treat non-usernameTaken
|
||
// as success and save locally (desktop parity: fallback save).
|
||
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
|
||
commitPendingAvatar()
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
isEditingProfile = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Send PacketUserInfo to server
|
||
if let hash = SessionManager.shared.privateKeyHash {
|
||
var packet = PacketUserInfo()
|
||
packet.username = trimmedUsername
|
||
packet.title = trimmedName
|
||
packet.privateKey = hash
|
||
ProtocolManager.shared.sendPacket(packet)
|
||
}
|
||
|
||
// 10s timeout — fallback to local save (Android parity)
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||
guard isSaving else { return }
|
||
// Server didn't respond — save locally as fallback
|
||
ProtocolManager.shared.removeResultHandler(handlerId)
|
||
isSaving = false
|
||
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
|
||
commitPendingAvatar()
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
isEditingProfile = false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Saves pending avatar photo to disk and updates the displayed avatar.
|
||
private func commitPendingAvatar() {
|
||
if let photo = pendingAvatarPhoto {
|
||
AvatarRepository.shared.saveAvatar(publicKey: viewModel.publicKey, image: photo)
|
||
avatarImage = photo
|
||
pendingAvatarPhoto = nil
|
||
}
|
||
}
|
||
|
||
private func updateLocalProfile(displayName: String, username: String) {
|
||
AccountManager.shared.updateProfile(
|
||
displayName: displayName,
|
||
username: username
|
||
)
|
||
SessionManager.shared.updateDisplayNameAndUsername(
|
||
displayName: displayName,
|
||
username: username
|
||
)
|
||
}
|
||
|
||
// MARK: - Settings Content
|
||
|
||
private var settingsContent: some View {
|
||
VStack(spacing: 0) {
|
||
profileHeader
|
||
|
||
accountSwitcherCard
|
||
|
||
// Desktop parity: separate cards with subtitle descriptions.
|
||
updatesCard
|
||
if BiometricAuthManager.shared.isBiometricAvailable {
|
||
biometricCard
|
||
}
|
||
themeCard
|
||
safetyCard
|
||
|
||
rosettaPowerFooter
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 0)
|
||
.padding(.bottom, 100)
|
||
}
|
||
|
||
/// Desktop parity: "rosetta — powering freedom" footer with small R icon.
|
||
private var rosettaPowerFooter: some View {
|
||
HStack(spacing: 6) {
|
||
RosettaLogoShape()
|
||
.fill(RosettaColors.Adaptive.textTertiary)
|
||
.frame(width: 11, height: 11)
|
||
|
||
Text("rosetta – powering freedom")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.top, 32)
|
||
}
|
||
|
||
// MARK: - Profile Header
|
||
|
||
private var profileHeader: some View {
|
||
VStack(spacing: 12) {
|
||
AvatarView(
|
||
initials: viewModel.initials,
|
||
colorIndex: viewModel.avatarColorIndex,
|
||
size: 80,
|
||
isSavedMessages: false,
|
||
image: avatarImage
|
||
)
|
||
|
||
VStack(spacing: 4) {
|
||
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)
|
||
}
|
||
}
|
||
|
||
CopyableText(
|
||
displayText: formatPublicKey(viewModel.publicKey),
|
||
fullText: viewModel.publicKey,
|
||
font: .monospacedSystemFont(ofSize: 12, weight: .regular),
|
||
textColor: UIColor(RosettaColors.tertiaryText)
|
||
)
|
||
.frame(height: 16)
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
// MARK: - Account Switcher Card
|
||
|
||
@State private var accountToDelete: Account?
|
||
@State private var showDeleteAccountSheet = false
|
||
|
||
private var accountSwitcherCard: some View {
|
||
let currentKey = AccountManager.shared.currentAccount?.publicKey
|
||
let otherAccounts = AccountManager.shared.allAccounts.filter { $0.publicKey != currentKey }
|
||
|
||
return SettingsCard {
|
||
VStack(spacing: 0) {
|
||
ForEach(Array(otherAccounts.enumerated()), id: \.element.publicKey) { index, account in
|
||
let position: SettingsRowPosition = index == 0 && otherAccounts.count == 1
|
||
? .top
|
||
: index == 0 ? .top : .middle
|
||
|
||
accountRow(account, position: position)
|
||
|
||
Divider()
|
||
.background(RosettaColors.Adaptive.divider)
|
||
.padding(.leading, 52)
|
||
}
|
||
|
||
addAccountRow(position: otherAccounts.isEmpty ? .alone : .bottom)
|
||
}
|
||
}
|
||
.padding(.top, 16)
|
||
.confirmationDialog(
|
||
"Are you sure you want to delete this account from this device?",
|
||
isPresented: $showDeleteAccountSheet,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("Delete", role: .destructive) {
|
||
if let acc = accountToDelete {
|
||
deleteOtherAccount(acc)
|
||
accountToDelete = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func accountRow(_ account: Account, position: SettingsRowPosition) -> some View {
|
||
let name = account.displayName ?? String(account.publicKey.prefix(7))
|
||
let initials = RosettaColors.initials(name: name, publicKey: account.publicKey)
|
||
let colorIndex = RosettaColors.avatarColorIndex(for: name, publicKey: account.publicKey)
|
||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: account.publicKey)
|
||
let unread = totalUnreadCount(for: account.publicKey)
|
||
|
||
return AccountSwipeRow(
|
||
position: position,
|
||
onTap: {
|
||
onLogout?()
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
|
||
AccountManager.shared.setActiveAccount(publicKey: account.publicKey)
|
||
SessionManager.shared.endSession()
|
||
}
|
||
},
|
||
onDelete: {
|
||
accountToDelete = account
|
||
showDeleteAccountSheet = true
|
||
}
|
||
) {
|
||
HStack(spacing: 12) {
|
||
AvatarView(
|
||
initials: initials,
|
||
colorIndex: colorIndex,
|
||
size: 30,
|
||
isSavedMessages: false,
|
||
image: avatarImage
|
||
)
|
||
|
||
Text(name)
|
||
.font(.system(size: 17, weight: .bold))
|
||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||
.lineLimit(1)
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
if unread > 0 {
|
||
Text(formattedUnreadCount(unread))
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(RosettaColors.primaryBlue)
|
||
.clipShape(Capsule())
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.frame(height: 48)
|
||
}
|
||
}
|
||
|
||
private func addAccountRow(position: SettingsRowPosition) -> some View {
|
||
Button {
|
||
showAddAccountSheet = true
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundStyle(RosettaColors.primaryBlue)
|
||
.frame(width: 30, height: 30)
|
||
|
||
Text("Add Account")
|
||
.font(.system(size: 17, weight: .medium))
|
||
.foregroundStyle(RosettaColors.primaryBlue)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.frame(height: 48)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.settingsHighlight(position: position)
|
||
}
|
||
|
||
/// Calculates total unread count for the given account's dialogs.
|
||
/// Only returns meaningful data for the currently active session.
|
||
/// Deletes another (non-active) account from the device.
|
||
private func deleteOtherAccount(_ account: Account) {
|
||
BiometricAuthManager.shared.clearAll(forAccount: account.publicKey)
|
||
AvatarRepository.shared.removeAvatar(publicKey: account.publicKey)
|
||
let defaults = UserDefaults.standard
|
||
defaults.removeObject(forKey: "rosetta_recent_searches_\(account.publicKey)")
|
||
defaults.removeObject(forKey: "rosetta_last_sync_\(account.publicKey)")
|
||
defaults.removeObject(forKey: "backgroundBlurColor_\(account.publicKey)")
|
||
AccountManager.shared.removeAccount(publicKey: account.publicKey)
|
||
}
|
||
|
||
private func totalUnreadCount(for accountPublicKey: String) -> Int {
|
||
guard accountPublicKey == SessionManager.shared.currentPublicKey else { return 0 }
|
||
return DialogRepository.shared.dialogs.values.reduce(0) { $0 + $1.unreadCount }
|
||
}
|
||
|
||
/// Formats unread count: "42" for < 1000, "1,2K" for >= 1000.
|
||
private func formattedUnreadCount(_ count: Int) -> String {
|
||
if count < 1000 {
|
||
return "\(count)"
|
||
}
|
||
let thousands = Double(count) / 1000.0
|
||
let formatted = String(format: "%.1f", thousands)
|
||
.replacingOccurrences(of: ".", with: ",")
|
||
return "\(formatted)K"
|
||
}
|
||
|
||
// MARK: - Desktop Parity Cards
|
||
|
||
private var updatesCard: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SettingsCard {
|
||
NavigationLink(value: SettingsDestination.updates) {
|
||
settingsRowLabel(
|
||
icon: "arrow.triangle.2.circlepath",
|
||
title: "Updates",
|
||
color: .green
|
||
)
|
||
}
|
||
.settingsHighlight()
|
||
}
|
||
Text("You can check for new versions of the app here. Updates may include security improvements and new features.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 8)
|
||
}
|
||
.padding(.top, 16)
|
||
}
|
||
|
||
private var themeCard: some View {
|
||
settingsCardWithSubtitle(
|
||
icon: "paintbrush.fill",
|
||
title: "Theme",
|
||
color: .indigo,
|
||
subtitle: "You can change the theme."
|
||
) {}
|
||
}
|
||
|
||
private var safetyCard: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SettingsCard {
|
||
NavigationLink(value: SettingsDestination.safety) {
|
||
settingsRowLabel(
|
||
icon: "shield.lefthalf.filled",
|
||
title: "Safety",
|
||
color: .purple
|
||
)
|
||
}
|
||
.settingsHighlight()
|
||
}
|
||
(
|
||
Text("You can learn more about your safety on the safety page, please make sure you are viewing the screen alone ")
|
||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||
+ Text("before proceeding to the safety page")
|
||
.bold()
|
||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||
+ Text(".")
|
||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||
)
|
||
.font(.system(size: 13))
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 8)
|
||
}
|
||
.padding(.top, 16)
|
||
}
|
||
|
||
private var logoutCard: some View {
|
||
settingsCardWithSubtitle(
|
||
icon: "rectangle.portrait.and.arrow.right",
|
||
title: "Logout",
|
||
color: RosettaColors.error,
|
||
titleColor: RosettaColors.error,
|
||
showChevron: false,
|
||
subtitle: "Logging out of your account. After logging out, you will be redirected to the password entry page."
|
||
) {
|
||
SessionManager.shared.endSession()
|
||
onLogout?()
|
||
}
|
||
}
|
||
|
||
// MARK: - Biometric Card
|
||
|
||
private var biometricCard: some View {
|
||
let biometric = BiometricAuthManager.shared
|
||
|
||
return VStack(alignment: .leading, spacing: 8) {
|
||
SettingsCard {
|
||
settingsToggle(
|
||
icon: biometric.biometricIconName,
|
||
title: biometric.biometricName,
|
||
color: .blue,
|
||
isOn: isBiometricEnabled
|
||
) { newValue in
|
||
if newValue {
|
||
// Show password prompt to enable
|
||
biometricPassword = ""
|
||
biometricError = nil
|
||
showBiometricPasswordPrompt = true
|
||
} else {
|
||
disableBiometric()
|
||
}
|
||
}
|
||
}
|
||
Text("Use \(biometric.biometricName) to unlock Rosetta instead of entering your password.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 8)
|
||
}
|
||
.padding(.top, 16)
|
||
}
|
||
|
||
/// Toggle row for settings (icon + title + Toggle).
|
||
private func settingsToggle(
|
||
icon: String,
|
||
title: String,
|
||
color: Color,
|
||
isOn: Bool,
|
||
action: @escaping (Bool) -> Void
|
||
) -> some View {
|
||
HStack(spacing: 0) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 16))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 26, height: 26)
|
||
.background(color)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
.padding(.trailing, 16)
|
||
|
||
Text(title)
|
||
.font(.system(size: 17, weight: .medium))
|
||
.tracking(-0.43)
|
||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||
.lineLimit(1)
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
Toggle("", isOn: Binding(
|
||
get: { isOn },
|
||
set: { action($0) }
|
||
))
|
||
.labelsHidden()
|
||
.tint(RosettaColors.primaryBlue)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.frame(height: 52)
|
||
}
|
||
|
||
private func refreshBiometricState() {
|
||
let publicKey = viewModel.publicKey
|
||
guard !publicKey.isEmpty else { return }
|
||
isBiometricEnabled = BiometricAuthManager.shared.isBiometricEnabled(forAccount: publicKey)
|
||
}
|
||
|
||
private func enableBiometric() {
|
||
let enteredPassword = biometricPassword
|
||
biometricPassword = ""
|
||
|
||
guard !enteredPassword.isEmpty else {
|
||
biometricError = "Password cannot be empty"
|
||
isBiometricEnabled = false
|
||
return
|
||
}
|
||
|
||
let publicKey = viewModel.publicKey
|
||
|
||
Task {
|
||
do {
|
||
// Verify password is correct by attempting unlock
|
||
_ = try await AccountManager.shared.unlock(password: enteredPassword)
|
||
|
||
// Password correct — save for biometric
|
||
let biometric = BiometricAuthManager.shared
|
||
try biometric.savePassword(enteredPassword, forAccount: publicKey)
|
||
biometric.setBiometricEnabled(true, forAccount: publicKey)
|
||
isBiometricEnabled = true
|
||
biometricError = nil
|
||
} catch {
|
||
isBiometricEnabled = false
|
||
biometricError = "Wrong password"
|
||
showBiometricPasswordPrompt = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private func disableBiometric() {
|
||
let publicKey = viewModel.publicKey
|
||
let biometric = BiometricAuthManager.shared
|
||
biometric.deletePassword(forAccount: publicKey)
|
||
biometric.setBiometricEnabled(false, forAccount: publicKey)
|
||
isBiometricEnabled = false
|
||
}
|
||
|
||
// MARK: - Danger Section
|
||
|
||
private var dangerSection: some View {
|
||
SettingsCard {
|
||
Button {
|
||
showDeleteAccountConfirmation = true
|
||
} label: {
|
||
HStack {
|
||
Spacer()
|
||
Text("Delete Account")
|
||
.font(.system(size: 17))
|
||
.foregroundStyle(RosettaColors.error)
|
||
Spacer()
|
||
}
|
||
.frame(height: 52)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.settingsHighlight()
|
||
}
|
||
}
|
||
|
||
// MARK: - Debug (stress test)
|
||
|
||
#if DEBUG
|
||
private var debugCard: some View {
|
||
SettingsCard {
|
||
Button {
|
||
StressTestGenerator.generateMessages(count: 100, dialogKey: "stress_test_\(Int.random(in: 1000...9999))")
|
||
} label: {
|
||
HStack {
|
||
Spacer()
|
||
Text("🧪 Generate 100 Messages + Photos")
|
||
.font(.system(size: 17))
|
||
.foregroundStyle(.orange)
|
||
Spacer()
|
||
}
|
||
.frame(height: 52)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.settingsHighlight()
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// 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,
|
||
titleColor: Color = RosettaColors.Adaptive.text,
|
||
detail: String? = nil,
|
||
showChevron: Bool = true,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
Button(action: action) {
|
||
settingsRowLabel(
|
||
icon: icon, title: title, color: color,
|
||
titleColor: titleColor, detail: detail,
|
||
showChevron: showChevron
|
||
)
|
||
}
|
||
.settingsHighlight()
|
||
}
|
||
|
||
/// Non-interactive row label for use inside NavigationLink.
|
||
private func settingsRowLabel(
|
||
icon: String,
|
||
title: String,
|
||
color: Color,
|
||
titleColor: Color = RosettaColors.Adaptive.text,
|
||
detail: String? = nil,
|
||
showChevron: Bool = true
|
||
) -> some View {
|
||
HStack(spacing: 0) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 16))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 26, height: 26)
|
||
.background(color)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
.padding(.trailing, 16)
|
||
|
||
Text(title)
|
||
.font(.system(size: 17, weight: .medium))
|
||
.tracking(-0.43)
|
||
.foregroundStyle(titleColor)
|
||
.lineLimit(1)
|
||
|
||
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)
|
||
.frame(height: 52)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
/// Desktop parity: card with a single settings row + subtitle text below.
|
||
private func settingsCardWithSubtitle(
|
||
icon: String,
|
||
title: String,
|
||
color: Color,
|
||
titleColor: Color = RosettaColors.Adaptive.text,
|
||
showChevron: Bool = true,
|
||
subtitle: String,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SettingsCard {
|
||
settingsRow(
|
||
icon: icon, title: title, color: color,
|
||
titleColor: titleColor, showChevron: showChevron,
|
||
action: action
|
||
)
|
||
}
|
||
Text(subtitle)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 8)
|
||
}
|
||
.padding(.top, 16)
|
||
}
|
||
|
||
private var sectionDivider: some View {
|
||
Divider()
|
||
.background(RosettaColors.Adaptive.divider)
|
||
.padding(.leading, 62)
|
||
}
|
||
|
||
private func formatPublicKey(_ key: String) -> String {
|
||
guard key.count > 16 else { return key }
|
||
return String(key.prefix(8)) + "..." + String(key.suffix(6))
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Account Swipe Row
|
||
|
||
/// Row with swipe-to-delete that doesn't conflict with tap action.
|
||
/// Uses a gesture-priority trick: horizontal DragGesture on a transparent overlay
|
||
/// takes priority only when horizontal movement exceeds vertical (swipe left).
|
||
/// Vertical drags and taps pass through to the content below.
|
||
private struct AccountSwipeRow<Content: View>: View {
|
||
let position: SettingsRowPosition
|
||
let onTap: () -> Void
|
||
let onDelete: () -> Void
|
||
@ViewBuilder let content: () -> Content
|
||
|
||
@State private var swipeOffset: CGFloat = 0
|
||
@State private var isRevealed = false
|
||
private let deleteWidth: CGFloat = 80
|
||
private let cardRadius: CGFloat = 12
|
||
|
||
/// Top-right corner radius based on row position in the card.
|
||
private var deleteTopRadius: CGFloat {
|
||
switch position {
|
||
case .top, .alone: return cardRadius
|
||
default: return 0
|
||
}
|
||
}
|
||
|
||
/// Bottom-right corner radius based on row position in the card.
|
||
private var deleteBottomRadius: CGFloat {
|
||
// Account rows are never .bottom — addAccountRow is always below.
|
||
// But handle it for completeness.
|
||
switch position {
|
||
case .bottom, .alone: return cardRadius
|
||
default: return 0
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
ZStack(alignment: .trailing) {
|
||
// Delete button behind — only when swiped
|
||
if swipeOffset < -5 {
|
||
HStack(spacing: 0) {
|
||
Spacer()
|
||
Button {
|
||
withAnimation(.easeOut(duration: 0.2)) {
|
||
swipeOffset = 0
|
||
isRevealed = false
|
||
}
|
||
onDelete()
|
||
} label: {
|
||
Text("Delete")
|
||
.font(.system(size: 17, weight: .regular))
|
||
.foregroundStyle(.white)
|
||
.frame(width: deleteWidth, height: geo.size.height)
|
||
.background(
|
||
UnevenRoundedRectangle(
|
||
topLeadingRadius: 0,
|
||
bottomLeadingRadius: 0,
|
||
bottomTrailingRadius: deleteBottomRadius,
|
||
topTrailingRadius: deleteTopRadius
|
||
)
|
||
.fill(Color.red)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Content row
|
||
content()
|
||
.contentShape(Rectangle())
|
||
.offset(x: swipeOffset)
|
||
.onTapGesture {
|
||
if isRevealed {
|
||
withAnimation(.easeOut(duration: 0.2)) {
|
||
swipeOffset = 0
|
||
isRevealed = false
|
||
}
|
||
} else {
|
||
onTap()
|
||
}
|
||
}
|
||
.highPriorityGesture(
|
||
DragGesture(minimumDistance: 20, coordinateSpace: .local)
|
||
.onChanged { value in
|
||
let dx = value.translation.width
|
||
let dy = value.translation.height
|
||
// Only respond to horizontal swipes
|
||
guard abs(dx) > abs(dy) else { return }
|
||
|
||
if isRevealed {
|
||
// Already open — allow closing
|
||
let newOffset = -deleteWidth + dx
|
||
swipeOffset = max(min(newOffset, 0), -deleteWidth - 20)
|
||
} else if dx < 0 {
|
||
// Swiping left to reveal
|
||
swipeOffset = max(dx, -deleteWidth - 20)
|
||
}
|
||
}
|
||
.onEnded { value in
|
||
let dx = value.translation.width
|
||
guard abs(dx) > abs(value.translation.height) else {
|
||
// Was vertical — snap back
|
||
withAnimation(.easeOut(duration: 0.2)) {
|
||
swipeOffset = isRevealed ? -deleteWidth : 0
|
||
}
|
||
return
|
||
}
|
||
|
||
withAnimation(.easeOut(duration: 0.2)) {
|
||
if swipeOffset < -deleteWidth / 2 {
|
||
swipeOffset = -deleteWidth
|
||
isRevealed = true
|
||
} else {
|
||
swipeOffset = 0
|
||
isRevealed = false
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
.frame(height: 48)
|
||
.clipped()
|
||
}
|
||
}
|