Files
mobile-ios/Rosetta/Features/Settings/SettingsView.swift

1031 lines
38 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}