From 578bfb07140acd435db048c8a8b14d79a5a6c751 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 23:00:16 -0400 Subject: [PATCH 1/2] feat(settings): Settings screen behind a gear on the You tab The You tab becomes the profile in later slices, so the rows it held move to a Settings screen reached from a gear on the tab: an interim Profile section (the My Account editors until Edit Profile exists), Security, Privacy, Advanced, and Account with a new Account Info screen (phone, email, user ID, authority public key), then the version footer. SettingsMyAccountScreen and SettingsAdvancedFeaturesScreen are removed, and the UI smoke tests now reach Settings through the gear. Base: origin/main 410bb5f7. --- .../Navigation/AppRouter+Destination.swift | 25 +- .../AppRouter+DestinationView.swift | 8 +- .../Core/Screens/Main/You/YouScreen.swift | 101 +++----- .../Screens/Settings/AccountInfoDetails.swift | 61 +++++ .../Screens/Settings/AccountInfoScreen.swift | 61 +++++ .../SettingsAdvancedBetaFeaturesScreen.swift | 18 +- .../SettingsAdvancedFeaturesScreen.swift | 119 ---------- .../Settings/SettingsMyAccountScreen.swift | 75 ------ .../Screens/Settings/SettingsScreen.swift | 222 ++++++++++++++++++ .../Settings/SettingsSectionHeader.swift | 29 +++ .../Navigation/AppRouterCrossStackTests.swift | 4 +- FlipcashTests/Navigation/AppRouterTests.swift | 2 +- .../Navigation/SettingsRoutingTests.swift | 38 +++ .../Navigation/SwitchAccountSheetTests.swift | 4 +- .../Navigation/YouTabRoutingTests.swift | 14 +- .../Settings/AccountInfoDetailsTests.swift | 80 +++++++ .../ApplicationLogsRegressionTests.swift | 5 +- .../Smoke/AccessKeyBackupSmokeTests.swift | 17 +- .../Smoke/BetaAccessUnlockSmokeTests.swift | 18 +- .../Smoke/BlockUnblockSmokeTests.swift | 9 +- .../Smoke/BlockedUsersSmokeTests.swift | 5 +- .../Smoke/DisplayNameSmokeTests.swift | 6 +- FlipcashUITests/Smoke/LoginSmokeTests.swift | 8 +- .../Support/Screens/SettingsScreen.swift | 50 ++-- 24 files changed, 598 insertions(+), 381 deletions(-) create mode 100644 Flipcash/Core/Screens/Settings/AccountInfoDetails.swift create mode 100644 Flipcash/Core/Screens/Settings/AccountInfoScreen.swift delete mode 100644 Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift delete mode 100644 Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift create mode 100644 Flipcash/Core/Screens/Settings/SettingsScreen.swift create mode 100644 Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift create mode 100644 FlipcashTests/Navigation/SettingsRoutingTests.swift create mode 100644 FlipcashTests/Settings/AccountInfoDetailsTests.swift diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ffc12148c..e64d65f6f 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -56,8 +56,11 @@ extension AppRouter { case usdcDepositAddress // Settings flow - case settingsMyAccount - /// The display name on its own, edited from My Account. The full + /// The Settings list, opened from the gear on the You tab. + case settings + /// The account's phone, email, user ID and public key, opened from Settings. + case accountInfo + /// The display name on its own, edited from Settings. The full /// profile-setup flow starts on the same screen but carries on to the /// tip card; this one returns to the settings list. case changeDisplayName @@ -65,15 +68,14 @@ extension AppRouter { /// The full profile-setup flow uses `.profilePhoto`, which carries on to /// the tip card; this one returns to the screen that opened it. case changeProfilePicture - /// The public handle, claimed from the You page or changed from My - /// Account. One destination for both: the screen seeds itself from the + /// The public handle, claimed from the You page or changed from + /// Settings. One destination for both: the screen seeds itself from the /// handle already on the profile, so there is nothing to distinguish. case username(Username?) /// The minimum a tipper must pay to open a DM. `isSetupStep` marks the - /// You tab's checklist apart from a lone edit in My Account; it reaches + /// You tab's checklist apart from a lone edit in Settings; it reaches /// the payload only, since the screen is the same from either. case setMinimumTip(isSetupStep: Bool) - case settingsAdvancedFeatures case settingsAdvancedBetaFeatures case settingsAppSettings case settingsAccountSelection @@ -141,8 +143,8 @@ extension AppRouter { .buyCurrency, .convertCurrency, .withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress: return .balance - case .settingsMyAccount, .changeDisplayName, .changeProfilePicture, .username, - .setMinimumTip, .settingsAdvancedFeatures, + case .settings, .accountInfo, .changeDisplayName, .changeProfilePicture, .username, + .setMinimumTip, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw: return .you @@ -176,12 +178,12 @@ extension AppRouter { case .withdrawCurrency: "withdrawCurrency" case .usdcDepositEducation: "usdcDepositEducation" case .usdcDepositAddress: "usdcDepositAddress" - case .settingsMyAccount: "settingsMyAccount" + case .settings: "settings" + case .accountInfo: "accountInfo" case .changeDisplayName: "changeDisplayName" case .changeProfilePicture: "changeProfilePicture" case .username: "username" case .setMinimumTip: "setMinimumTip" - case .settingsAdvancedFeatures: "settingsAdvancedFeatures" case .settingsAdvancedBetaFeatures: "settingsAdvancedBetaFeatures" case .settingsAppSettings: "settingsAppSettings" case .settingsAccountSelection: "settingsAccountSelection" @@ -243,8 +245,7 @@ extension AppRouter { case .activity, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .usdcDepositEducation, .usdcDepositAddress, - .settingsMyAccount, .changeDisplayName, .changeProfilePicture, - .settingsAdvancedFeatures, + .settings, .accountInfo, .changeDisplayName, .changeProfilePicture, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw, .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index f5068a2ce..0951a5d72 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -82,8 +82,10 @@ struct DestinationView: View { // MARK: - Settings flow - case .settingsMyAccount: - SettingsMyAccountScreen() + case .settings: + SettingsScreen() + case .accountInfo: + AccountInfoScreen() case .changeDisplayName: ChangeDisplayNameScreen(currentName: sessionContainer.session.profile?.displayName ?? "") @@ -106,8 +108,6 @@ struct DestinationView: View { ) ) - case .settingsAdvancedFeatures: - SettingsAdvancedFeaturesScreen() case .settingsAdvancedBetaFeatures: SettingsAdvancedBetaFeaturesScreen() diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index fd487c994..eff197d2c 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -10,7 +10,7 @@ import FlipcashUI /// The "You" tab (Figma node 9276:4634): the user's tip card, a "Full Screen" /// affordance, the copyable public link, the Share/Download pair, and the -/// settings list — the tab-bar entry into My Account / Advanced. +/// gear that pushes Settings. /// /// Tapping the card (or "Full Screen") expands it here on the page: everything /// under it slides out, the tab bar hides, and the card grows into the middle of @@ -18,31 +18,22 @@ import FlipcashUI /// nothing is pushed or presented — so the card never leaves this screen. /// Pulling the expanded card up walks it back to its slot under the finger, so /// it can be put away without reaching for the close button. -/// Settings rows push onto the tab's `.you` stack, so they never touch the v1 +/// Settings pushes onto the tab's `.you` stack, so it never touches the v1 /// scanner's Settings sheet. /// /// A profile with no display name has no card: the page then shows the /// add-your-name invitation in the card's place and drops the link and share -/// affordances, but still renders — the settings rows are this account's only -/// way to reach My Account, and with it Log Out. -/// -/// The two settings rows carry Android's own drawables (`MenuItems.kt`) rather -/// than SF Symbols: `person.text.rectangle` is already the Display Name row's -/// glyph one screen down, so the nearest system symbol would read as a repeat. +/// affordances, but still renders — the gear is this account's only way to +/// reach Settings, and with it Log Out. struct YouScreen: View { @Environment(SessionContainer.self) private var sessionContainer @Environment(AppRouter.self) private var router - @Environment(BetaFlags.self) private var betaFlags @Environment(TipCardPresentation.self) private var presentation /// Warms the share-sheet preview image ahead of the share tap so it never /// lands on the tap; keyed by user. @State private var previewCache = TipCodePreviewCache() - /// The version footer's beta-access easter egg — the tap count and the line - /// it shows for the last few taps. - @State private var versionUnlock = VersionTapUnlock() - @Environment(ToastController.self) private var toasts @State private var isShowingDownloadOptions = false /// The format tapped in the download sheet, held until the sheet is gone so @@ -94,9 +85,7 @@ struct YouScreen: View { /// the card picks up from where it stands instead of jumping that distance. private static let dragActivationSlack: CGFloat = 10 - private let rowInsets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) - - /// The gap the page keeps between the version footer and the tab bar. + /// The gap the page keeps between its last row and the tab bar. private static let tabBarGap: CGFloat = 24 /// How far below the safe area the top fade runs out. Matches `WalletScreen`. @@ -157,6 +146,13 @@ struct YouScreen: View { } .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { safeAreaTop = $0 } + // Sibling of the scroll view, not an overlay on it: the ZStack + // already sits below the status bar, so no `safeAreaTop` offset. + settingsGear + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .opacity(isExpanded ? 0 : 1) + .allowsHitTesting(!isExpanded) + if isExpanded { closeButton .transition(.opacity) @@ -191,7 +187,7 @@ struct YouScreen: View { /// /// A profile without a display name has no card to draw, so the slot carries /// the invitation to add one instead. The rest of the page stays where it is - /// either way — settings included, which is the only route to logging out. + /// either way — the gear included, which is the only route to logging out. @ViewBuilder private var cardSection: some View { if let name = displayName { @@ -348,13 +344,31 @@ struct YouScreen: View { .opacity(0.5) } + // MARK: - Settings gear - + + private var settingsGear: some View { + Button { + router.push(.settings) + } label: { + Image(systemName: "gearshape") + .font(.system(size: 20)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.top, 4) + .padding(.trailing, 8) + .accessibilityLabel("Settings") + .accessibilityIdentifier("you-settings") + } + // MARK: - Page - /// Everything below the card — the part that clears out when the card expands. /// /// The link and the Share/Download pair all address a card that a name-less - /// profile does not have, so they sit out until it does; the settings rows - /// take up the slack. The checklist shows either way, since naming the + /// profile does not have, so they sit out until it does. The checklist shows either way, since naming the /// profile is one of its chores. private var pageContent: some View { VStack(spacing: 0) { @@ -395,30 +409,7 @@ struct YouScreen: View { .accessibilityIdentifier("you-username-progress-card") } } - - settingsList - .padding(.top, displayName == nil && !profileTutorialState.isVisible ? 48 : 19) - - // v2 scrolls the version string with the content rather than - // pinning it above the tab bar (the v1 Settings sheet pinned it). - versionFooter - .padding(.top, 32) - } - } - - // MARK: - Settings list - - - private var settingsList: some View { - VStack(alignment: .leading, spacing: 0) { - SettingsRow(asset: .myAccount, title: "My Account", insets: rowInsets) { - router.push(.settingsMyAccount) - } - SettingsRow(asset: .advanced, title: "Advanced", insets: rowInsets) { - router.push(.settingsAdvancedFeatures) - } } - .font(.appDisplayXS) - .foregroundStyle(Color.textMain) } // MARK: - Username progress - @@ -456,32 +447,6 @@ struct YouScreen: View { } } - private var versionFooter: some View { - Button { - let message = versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { - betaFlags.setAccessGranted(!betaFlags.accessGranted) - } - if let message { - // Two seconds and swapped in place, so consecutive taps read as one countdown. Without an action the toast - // lets taps through, so it never blocks the version string at the bottom of the list. - toasts.show( - .init(message, messageIdentifier: "you-version-toast", width: .fit, duration: .seconds(2)), - inPlace: true - ) - } - } label: { - Text("Version \(AppMeta.version) • Build \(AppMeta.build)") - .lineLimit(1) - .font(.appTextHeading) - .foregroundStyle(Color.textSecondary) - .padding(.vertical, 12) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier("you-version-footer") - } - // MARK: - Content - private var isExpanded: Bool { presentation.isExpanded } diff --git a/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift b/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift new file mode 100644 index 000000000..c80b8f293 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift @@ -0,0 +1,61 @@ +// +// AccountInfoDetails.swift +// Flipcash +// + +import Foundation +import FlipcashCore + +/// The rows on the Account Info screen, built from the account's identity. +/// +/// Named `AccountInfoDetails` because `AccountInfo` is already the on-chain +/// token-account model in FlipcashCore. +nonisolated struct AccountInfoDetails { + + struct Row: Hashable { + let title: String + let display: String + let copyValue: String + let accessibilityID: String + } + + /// Phone and email only when the profile has them, then the account ID and + /// the owner authority's public key. + static func rows(profile: Profile?, userID: UserID, authority: PublicKey) -> [Row] { + var rows: [Row] = [] + + if let phone = profile?.phone { + rows.append(Row( + title: "Phone", + display: phone.national, + copyValue: phone.e164, + accessibilityID: "account-info-phone" + )) + } + + if let email = profile?.email, !email.isEmpty { + rows.append(Row( + title: "Email", + display: email, + copyValue: email, + accessibilityID: "account-info-email" + )) + } + + rows.append(Row( + title: "Account ID", + display: userID.uuidString, + copyValue: userID.uuidString, + accessibilityID: "account-info-account-id" + )) + + rows.append(Row( + title: "Public Key", + display: authority.base58, + copyValue: authority.base58, + accessibilityID: "account-info-public-key" + )) + + return rows + } +} diff --git a/Flipcash/Core/Screens/Settings/AccountInfoScreen.swift b/Flipcash/Core/Screens/Settings/AccountInfoScreen.swift new file mode 100644 index 000000000..528933184 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/AccountInfoScreen.swift @@ -0,0 +1,61 @@ +// +// AccountInfoScreen.swift +// Flipcash +// + +import SwiftUI +import UIKit +import FlipcashUI +import FlipcashCore + +/// The account's phone, email, user ID and owner public key. Tapping a row +/// copies its value. +struct AccountInfoScreen: View { + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(ToastController.self) private var toasts + + var body: some View { + let session = sessionContainer.session + let rows = AccountInfoDetails.rows( + profile: session.profile, + userID: session.userID, + authority: session.owner.authorityPublicKey + ) + + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(rows, id: \.self) { row in + Button { + copy(row) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(row.title) + .font(.appTextHeading) + .foregroundStyle(Color.textSecondary) + Text(row.display) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(row.accessibilityID) + } + } + } + .padding(.horizontal, 20) + } + .navigationTitle("Account Info") + .toolbarTitleDisplayMode(.inline) + } + + private func copy(_ row: AccountInfoDetails.Row) { + UIPasteboard.general.string = row.copyValue + toasts.show(.init("Copied", systemImage: "checkmark.circle.fill", duration: .seconds(2))) + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift index 56c3893e7..acdae54d4 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift @@ -53,14 +53,14 @@ struct SettingsAdvancedBetaFeaturesScreen: View { if betaFlags.accessGranted { if !developerOptions.isEmpty { - sectionHeader("Developer") + SettingsSectionHeader("Developer") ForEach(developerOptions) { option in BetaFlagToggleRow(option: option, isOn: betaFlags.bindingFor(option: option)) } } - sectionHeader("Account") + SettingsSectionHeader("Account") unlinkRow(title: "Unlink Email", isDisabled: session.profile?.email == nil) { isConfirmingUnlinkEmail = true @@ -69,7 +69,7 @@ struct SettingsAdvancedBetaFeaturesScreen: View { isConfirmingUnlinkPhone = true } - sectionHeader("Contracts") + SettingsSectionHeader("Contracts") ForEach(ContractInfo.all) { info in ContractInfoRow(info: info) @@ -121,18 +121,6 @@ struct SettingsAdvancedBetaFeaturesScreen: View { // MARK: - Subviews - - private func sectionHeader(_ title: String) -> some View { - HStack { - Text(title) - .font(.appTextHeading) - .foregroundStyle(.textSecondary) - Spacer() - } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 8) - } - private func unlinkRow(title: String, isDisabled: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift deleted file mode 100644 index c8242ed50..000000000 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// SettingsAdvancedFeaturesScreen.swift -// Flipcash -// -// Created by Raul Riera on 2026-04-27. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -/// The Advanced settings list (Figma node 9279:121978): the account-level -/// actions — Access Key, Switch Accounts, Log Out, Delete Account — alongside -/// Beta Features and Application Logs. My Account keeps the rows about the -/// profile this account presents, and who it will deal with. -/// -/// The row icons track Android's (`AdvancedFeatureMenuItems.kt`) through the -/// nearest SF Symbol. -struct SettingsAdvancedFeaturesScreen: View { - - @Environment(AppRouter.self) private var router - @Environment(BetaFlags.self) private var betaFlags - @Environment(SessionAuthenticator.self) private var sessionAuthenticator - @Environment(ContactSyncController.self) private var contactSyncController - - @State private var dialogItem: DialogItem? - - private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) - - var body: some View { - Background(color: .backgroundMain) { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - SettingsRow(systemImage: "key.horizontal", title: "Access Key", insets: insets) { - dialogItem = .alert( - title: "View Your Access Key?", - subtitle: "Your Access Key will grant access to your Flipcash account. Keep it private and safe" - ) { - DialogAction.destructive("View Access Key") { - router.push(.accessKey) - } - DialogAction.cancel() - } - } - - SettingsRow(systemImage: "flask", title: "Beta Features", insets: insets) { - router.push(.settingsAdvancedBetaFeatures) - } - - SettingsRow(systemImage: "doc.text", title: "Application Logs", insets: insets) { - router.push(.settingsApplicationLogs) - } - - // Sits with the other beta tool rather than on My Account: it is - // a way out of this account, next to Log Out, not a detail of it. - if betaFlags.canSwitchAccounts { - SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: .beta, insets: insets) { - router.push(.settingsAccountSelection) - } - .accessibilityIdentifier("account-switch-accounts-row") - } - - SettingsRow(asset: .logout, title: "Log Out", insets: insets) { - dialogItem = .alert( - title: "Are You Sure You Want To Log Out?", - subtitle: "You can get into this account using your Access Key" - ) { - DialogAction.destructive("Log Out") { - logout() - } - DialogAction.cancel() - } - } - - SettingsRow(asset: .delete, title: "Delete Account", insets: insets) { - dialogItem = .alert( - title: "Permanently Delete Account?", - subtitle: "This will permanently delete your Flipcash account" - ) { - DialogAction.destructive("Permanently Delete Account") { - deleteAccount() - } - DialogAction.cancel() - } - } - } - .font(.appDisplayXS) - .foregroundStyle(.textMain) - .padding(.horizontal, 20) - } - } - .navigationTitle("Advanced") - .toolbarTitleDisplayMode(.inline) - .dialog(item: $dialogItem) - } - - // MARK: - Actions - - - private func deleteAccount() { - Task { - router.dismissSheet() - try await Task.delay(milliseconds: 250) - // No server-side account deletion exists; logout only tears down - // locally. Wipe the server's stored contact set first so it isn't - // retained after the account is "deleted". Best-effort — must run - // before logout while the session can still authenticate the call. - await contactSyncController.clearServerContactSetForAccountDeletion() - sessionAuthenticator.logout() - } - } - - private func logout() { - Task { - router.dismissSheet() - try await Task.delay(milliseconds: 250) - sessionAuthenticator.logout() - } - } -} diff --git a/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift b/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift deleted file mode 100644 index 78e9b44ba..000000000 --- a/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// SettingsMyAccountScreen.swift -// Flipcash -// -// Created by Raul Riera on 2026-04-27. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -/// The My Account settings list (Figma node 9544:18478): who this account -/// deals with, and the profile it presents. The account-level actions — Access -/// Key, Switch Accounts, Log Out, Delete Account — live on Advanced. -/// -/// The design also lists Require Biometrics, which isn't here: iOS has no -/// biometrics setting to toggle. -/// -/// The row icons track Android's (`MyAccountMenuItems.kt`) through the nearest -/// SF Symbol, so the two platforms read alike without importing Material into -/// a system-symbol set. Profile Picture is the exception: `familiar_face_and_zone` -/// has no SF equivalent, so it ships as an asset. -struct SettingsMyAccountScreen: View { - - @Environment(AppRouter.self) private var router - @Environment(Session.self) private var session - - private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) - - var body: some View { - Background(color: .backgroundMain) { - ScrollView(showsIndicators: false) { - list() - } - .padding(.horizontal, 20) - } - .navigationTitle("My Account") - .toolbarTitleDisplayMode(.inline) - } - - @ViewBuilder - private func list() -> some View { - VStack(alignment: .leading, spacing: 0) { - SettingsRow(systemImage: "person.text.rectangle", title: "Display Name", insets: insets) { - router.push(.changeDisplayName) - } - - // No balance gate here: the gate exists to stop squatting at claim time. A user who - // already holds a handle has cleared it, and re-gating a change would hold their - // handle hostage to a balance that has since moved. - if let username = session.profile?.username { - SettingsRow(systemImage: "at", title: "Username", insets: insets) { - router.push(.username(username)) - } - .accessibilityIdentifier("account-change-username-row") - } - - SettingsRow(asset: .profilePicture, title: "Profile Picture", insets: insets) { - router.push(.changeProfilePicture) - } - .accessibilityIdentifier("account-profile-picture-row") - - SettingsRow(asset: .coins, title: "Minimum To Chat", insets: insets) { - router.push(.setMinimumTip(isSetupStep: false)) - } - .accessibilityIdentifier("account-minimum-tip-row") - - SettingsRow(systemImage: "nosign", title: "Blocked", insets: insets) { - router.push(.blockedUsers) - } - } - .font(.appDisplayXS) - .foregroundStyle(.textMain) - } -} diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift new file mode 100644 index 000000000..2b1c0e785 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -0,0 +1,222 @@ +// +// SettingsScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The Settings list, pushed from the gear on the You tab: the profile editors, +/// the account-level actions, and the version footer that doubles as the +/// beta-access easter egg. +/// +/// The row icons track Android's through the nearest SF Symbol. Profile Picture +/// is the exception: `familiar_face_and_zone` has no SF equivalent, so it ships +/// as an asset. +struct SettingsScreen: View { + + @Environment(AppRouter.self) private var router + @Environment(Session.self) private var session + @Environment(BetaFlags.self) private var betaFlags + @Environment(SessionAuthenticator.self) private var sessionAuthenticator + @Environment(ContactSyncController.self) private var contactSyncController + @Environment(ToastController.self) private var toasts + + @State private var dialogItem: DialogItem? + /// The version footer's beta-access easter egg — the tap count and the line + /// it shows for the last few taps. + @State private var versionUnlock = VersionTapUnlock() + + // Horizontal inset lives on the rows, not the list: the section headers + // carry their own 20pt. + private let insets = EdgeInsets(top: 25, leading: 20, bottom: 25, trailing: 20) + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + profileSection + securitySection + privacySection + advancedSection + accountSection + versionFooter + .padding(.top, 32) + } + .font(.appDisplayXS) + .foregroundStyle(.textMain) + .padding(.bottom, 24) + } + } + .navigationTitle("Settings") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialogItem) + } + + // MARK: - Sections - + + /// Interim: Edit Profile replaces this section. + private var profileSection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Profile") + + SettingsRow(systemImage: "person.text.rectangle", title: "Display Name", insets: insets) { + router.push(.changeDisplayName) + } + + // No balance gate here: the gate exists to stop squatting at claim time. A user who + // already holds a handle has cleared it, and re-gating a change would hold their + // handle hostage to a balance that has since moved. + if let username = session.profile?.username { + SettingsRow(systemImage: "at", title: "Username", insets: insets) { + router.push(.username(username)) + } + .accessibilityIdentifier("account-change-username-row") + } + + SettingsRow(asset: .profilePicture, title: "Profile Picture", insets: insets) { + router.push(.changeProfilePicture) + } + .accessibilityIdentifier("account-profile-picture-row") + + SettingsRow(asset: .coins, title: "Minimum To Chat", insets: insets) { + router.push(.setMinimumTip(isSetupStep: false)) + } + .accessibilityIdentifier("account-minimum-tip-row") + } + } + + private var securitySection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Security") + + SettingsRow(systemImage: "key.horizontal", title: "Access Key", insets: insets) { + dialogItem = .alert( + title: "View Your Access Key?", + subtitle: "Your Access Key will grant access to your Flipcash account. Keep it private and safe" + ) { + DialogAction.destructive("View Access Key") { + router.push(.accessKey) + } + DialogAction.cancel() + } + } + } + } + + private var privacySection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Privacy") + + SettingsRow(systemImage: "nosign", title: "Blocked", insets: insets) { + router.push(.blockedUsers) + } + } + } + + private var advancedSection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Advanced") + + SettingsRow(systemImage: "flask", title: "Beta Features", insets: insets) { + router.push(.settingsAdvancedBetaFeatures) + } + + SettingsRow(systemImage: "doc.text", title: "Application Logs", insets: insets) { + router.push(.settingsApplicationLogs) + } + + if betaFlags.canSwitchAccounts { + SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: .beta, insets: insets) { + router.push(.settingsAccountSelection) + } + .accessibilityIdentifier("account-switch-accounts-row") + } + } + } + + private var accountSection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Account") + + SettingsRow(systemImage: "person.crop.circle", title: "Account Info", insets: insets) { + router.push(.accountInfo) + } + .accessibilityIdentifier("settings-account-info-row") + + SettingsRow(asset: .logout, title: "Log Out", insets: insets) { + dialogItem = .alert( + title: "Are You Sure You Want To Log Out?", + subtitle: "You can get into this account using your Access Key" + ) { + DialogAction.destructive("Log Out") { + logout() + } + DialogAction.cancel() + } + } + + SettingsRow(asset: .delete, title: "Delete Account", insets: insets) { + dialogItem = .alert( + title: "Permanently Delete Account?", + subtitle: "This will permanently delete your Flipcash account" + ) { + DialogAction.destructive("Permanently Delete Account") { + deleteAccount() + } + DialogAction.cancel() + } + } + } + } + + private var versionFooter: some View { + Button { + let message = versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { + betaFlags.setAccessGranted(!betaFlags.accessGranted) + } + if let message { + // Two seconds and swapped in place, so consecutive taps read as one countdown. Without an action the toast + // lets taps through, so it never blocks the version string at the bottom of the list. + toasts.show( + .init(message, messageIdentifier: "you-version-toast", width: .fit, duration: .seconds(2)), + inPlace: true + ) + } + } label: { + Text("Version \(AppMeta.version) • Build \(AppMeta.build)") + .lineLimit(1) + .font(.appTextHeading) + .foregroundStyle(Color.textSecondary) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("you-version-footer") + } + + // MARK: - Actions - + + private func deleteAccount() { + Task { + router.dismissSheet() + try await Task.delay(milliseconds: 250) + // No server-side account deletion exists; logout only tears down + // locally. Wipe the server's stored contact set first so it isn't + // retained after the account is "deleted". Best-effort — must run + // before logout while the session can still authenticate the call. + await contactSyncController.clearServerContactSetForAccountDeletion() + sessionAuthenticator.logout() + } + } + + private func logout() { + Task { + router.dismissSheet() + try await Task.delay(milliseconds: 250) + sessionAuthenticator.logout() + } + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift new file mode 100644 index 000000000..61cc0d50a --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift @@ -0,0 +1,29 @@ +// +// SettingsSectionHeader.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// The small grey heading that opens a group of rows on a settings screen. +struct SettingsSectionHeader: View { + + let title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + HStack { + Text(title) + .font(.appTextHeading) + .foregroundStyle(.textSecondary) + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 8) + } +} diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index 4ae532dd0..fd1ff0ad2 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -150,9 +150,9 @@ struct AppRouterCrossStackTests { (AppRouter.Destination.withdrawCurrency(.usdc), AppRouter.Stack.balance), (AppRouter.Destination.usdcDepositEducation, AppRouter.Stack.balance), (AppRouter.Destination.usdcDepositAddress, AppRouter.Stack.balance), - (AppRouter.Destination.settingsMyAccount, AppRouter.Stack.you), + (AppRouter.Destination.settings, AppRouter.Stack.you), + (AppRouter.Destination.accountInfo, AppRouter.Stack.you), (AppRouter.Destination.changeDisplayName, AppRouter.Stack.you), - (AppRouter.Destination.settingsAdvancedFeatures, AppRouter.Stack.you), (AppRouter.Destination.settingsAppSettings, AppRouter.Stack.you), (AppRouter.Destination.settingsAdvancedBetaFeatures, AppRouter.Stack.you), (AppRouter.Destination.settingsAccountSelection, AppRouter.Stack.you), diff --git a/FlipcashTests/Navigation/AppRouterTests.swift b/FlipcashTests/Navigation/AppRouterTests.swift index 051645399..57ac1e587 100644 --- a/FlipcashTests/Navigation/AppRouterTests.swift +++ b/FlipcashTests/Navigation/AppRouterTests.swift @@ -370,7 +370,7 @@ struct AppRouterTests { AppRouter.Destination.discoverCurrencies, AppRouter.Destination.currencyCreationSummary, AppRouter.Destination.currencyCreationWizard, - AppRouter.Destination.settingsMyAccount, + AppRouter.Destination.settings, AppRouter.Destination.withdraw, ] ) diff --git a/FlipcashTests/Navigation/SettingsRoutingTests.swift b/FlipcashTests/Navigation/SettingsRoutingTests.swift new file mode 100644 index 000000000..72ef20b3d --- /dev/null +++ b/FlipcashTests/Navigation/SettingsRoutingTests.swift @@ -0,0 +1,38 @@ +// +// SettingsRoutingTests.swift +// FlipcashTests +// + +import Testing +@testable import Flipcash + +@MainActor +@Suite("Settings routing") +struct SettingsRoutingTests { + + @Test("Settings and Account Info are owned by the You stack", arguments: [ + AppRouter.Destination.settings, + AppRouter.Destination.accountInfo, + ]) + func owningStack(destination: AppRouter.Destination) { + #expect(destination.owningStack == .you) + } + + @Test("Pushing Settings from the You tab lands on the You stack") + func pushLandsOnYou() { + let router = AppRouter() + + router.activeTabStack = .you + router.push(.settings) + + #expect(router[.you] == AppRouter.navigationPath(.settings)) + } + + @Test("Log names are stable and carry no payload") + func logNames() { + #expect(AppRouter.Destination.settings.description == "settings") + #expect(AppRouter.Destination.accountInfo.description == "accountInfo") + #expect(AppRouter.Destination.settings.payload == nil) + #expect(AppRouter.Destination.accountInfo.payload == nil) + } +} diff --git a/FlipcashTests/Navigation/SwitchAccountSheetTests.swift b/FlipcashTests/Navigation/SwitchAccountSheetTests.swift index a40a7338e..c9cb79ee2 100644 --- a/FlipcashTests/Navigation/SwitchAccountSheetTests.swift +++ b/FlipcashTests/Navigation/SwitchAccountSheetTests.swift @@ -43,14 +43,14 @@ struct SwitchAccountSheetTests { func dismiss_returnsToTab() { let router = AppRouter() router.activeTabStack = .you - router.push(.settingsMyAccount) + router.push(.settings) router.present(.switchAccount) router.dismissSheet() #expect(router.presentedSheet == nil) #expect(router.activeTabStack == .you) - #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount)) + #expect(router[.you] == AppRouter.navigationPath(.settings)) } @Test("a self tipcard scan dismisses the switcher on its way to the You tab") diff --git a/FlipcashTests/Navigation/YouTabRoutingTests.swift b/FlipcashTests/Navigation/YouTabRoutingTests.swift index 075464e84..d50055124 100644 --- a/FlipcashTests/Navigation/YouTabRoutingTests.swift +++ b/FlipcashTests/Navigation/YouTabRoutingTests.swift @@ -22,8 +22,8 @@ struct YouTabRoutingTests { func push_onActiveYouTab_landsOnYouStack() { let router = AppRouter() router.activeTabStack = .you - router.push(.settingsMyAccount) - #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount)) + router.push(.settings) + #expect(router[.you] == AppRouter.navigationPath(.settings)) #expect(router[.balance].isEmpty) // never leaks onto another tab's stack } @@ -31,7 +31,7 @@ struct YouTabRoutingTests { func showOwnTipCard_inTabUI_selectsYouTabAtRoot() { let router = AppRouter() // Drilled into My Account behind a sheet — where a self link can land. - router.setPath([.settingsMyAccount], on: .you) + router.setPath([.settings], on: .you) router.present(.give) router.showOwnTipCard() @@ -60,7 +60,7 @@ struct YouTabRoutingTests { func showOwnTipCard_fromPushedYouScreen_popsToRoot() { let router = AppRouter() router.activeTabStack = .you - router.push(.settingsMyAccount) + router.push(.settings) router.showOwnTipCard() @@ -72,13 +72,13 @@ struct YouTabRoutingTests { func changeDisplayName_pushesAndPopsOnYouStack() { let router = AppRouter() router.activeTabStack = .you - router.push(.settingsMyAccount) + router.push(.settings) router.push(.changeDisplayName) - #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount, .changeDisplayName)) + #expect(router[.you] == AppRouter.navigationPath(.settings, .changeDisplayName)) // What `ProfileNameScreen(completion: .back)` runs once the name saves. router.popTopmost() - #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount)) + #expect(router[.you] == AppRouter.navigationPath(.settings)) } @Test("changing the profile picture belongs to the You tab, not the setup flow's stack") diff --git a/FlipcashTests/Settings/AccountInfoDetailsTests.swift b/FlipcashTests/Settings/AccountInfoDetailsTests.swift new file mode 100644 index 000000000..923abc168 --- /dev/null +++ b/FlipcashTests/Settings/AccountInfoDetailsTests.swift @@ -0,0 +1,80 @@ +// +// AccountInfoDetailsTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("AccountInfoDetails") +struct AccountInfoDetailsTests { + + private let userID = UUID() + private let authority = PublicKey.usdc + + private func profile(phone: Phone?, email: String?) -> Profile { + Profile(displayName: "Ada", phone: phone, email: email) + } + + @Test("All fields present: phone, email, account ID, public key in order") + func allFields() throws { + let phone = try #require(Phone("+14155552671")) + let rows = AccountInfoDetails.rows( + profile: profile(phone: phone, email: "ada@example.com"), + userID: userID, + authority: authority + ) + + #expect(rows.map(\.title) == ["Phone", "Email", "Account ID", "Public Key"]) + #expect(rows.map(\.display) == [phone.national, "ada@example.com", userID.uuidString, authority.base58]) + #expect(rows.map(\.accessibilityID) == [ + "account-info-phone", "account-info-email", "account-info-account-id", "account-info-public-key", + ]) + } + + @Test("Phone copies the E.164 form, not the national display") + func phoneCopiesE164() throws { + let phone = try #require(Phone("+14155552671")) + let rows = AccountInfoDetails.rows( + profile: profile(phone: phone, email: nil), + userID: userID, + authority: authority + ) + + let row = try #require(rows.first) + #expect(row.copyValue == phone.e164) + #expect(row.display == phone.national) + } + + @Test("No phone and no email leaves account ID and public key") + func noContact() { + let rows = AccountInfoDetails.rows( + profile: profile(phone: nil, email: nil), + userID: userID, + authority: authority + ) + + #expect(rows.map(\.title) == ["Account ID", "Public Key"]) + #expect(rows.map(\.copyValue) == [userID.uuidString, authority.base58]) + } + + @Test("An empty email string yields no email row") + func emptyEmail() { + let rows = AccountInfoDetails.rows( + profile: profile(phone: nil, email: ""), + userID: userID, + authority: authority + ) + + #expect(!rows.contains { $0.title == "Email" }) + } + + @Test("A missing profile still shows account ID and public key") + func nilProfile() { + let rows = AccountInfoDetails.rows(profile: nil, userID: userID, authority: authority) + + #expect(rows.map(\.title) == ["Account ID", "Public Key"]) + } +} diff --git a/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift b/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift index beab4c6fd..dd50d57e2 100644 --- a/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift +++ b/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift @@ -18,10 +18,9 @@ final class ApplicationLogsRegressionTests: BaseUITestCase { assertMainScreenReached() - // Navigate: Wallet → You → Advanced → Application Logs + // Navigate: Wallet → You → gear → Application Logs settings.open(from: self) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(settings.applicationLogsRow) + scrollUpToAndTap(settings.applicationLogsRow, in: settings.scrollView) // Verify the Application Logs screen is displayed let title = app.navigationBars["Application Logs"] diff --git a/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift b/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift index 422d948b2..a8b97a9bd 100644 --- a/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift +++ b/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift @@ -19,12 +19,9 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { assertMainScreenReached() - // Navigate: Wallet → You → Advanced → Access Key. The row is on - // Advanced, not My Account — `SettingsMyAccountScreen` keeps the - // account-level actions (Access Key, Log Out, Delete Account) off itself. + // Navigate: Wallet → You → gear → Access Key (Security section). settings.open(from: self) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(settings.accessKeyRow) + scrollUpToAndTap(settings.accessKeyRow, in: settings.scrollView) // Confirmation dialog: "View Your Access Key?" let dialog = app.otherElements["View Your Access Key?"] @@ -44,10 +41,9 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { assertMainScreenReached() - // Wallet → You → Advanced → Access Key. + // Wallet → You → gear → Access Key. settings.open(from: self) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(settings.accessKeyRow) + scrollUpToAndTap(settings.accessKeyRow, in: settings.scrollView) let dialog = app.otherElements["View Your Access Key?"] XCTAssertTrue(dialog.waitForExistence(timeout: 10), "Expected access key confirmation dialog") @@ -77,10 +73,9 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { assertMainScreenReached() - // Wallet → You → Advanced → Access Key. + // Wallet → You → gear → Access Key. settings.open(from: self) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(settings.accessKeyRow) + scrollUpToAndTap(settings.accessKeyRow, in: settings.scrollView) let dialog = app.otherElements["View Your Access Key?"] XCTAssertTrue(dialog.waitForExistence(timeout: 10), "Expected access key confirmation dialog") diff --git a/FlipcashUITests/Smoke/BetaAccessUnlockSmokeTests.swift b/FlipcashUITests/Smoke/BetaAccessUnlockSmokeTests.swift index 2d710b29a..1be0f3e93 100644 --- a/FlipcashUITests/Smoke/BetaAccessUnlockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BetaAccessUnlockSmokeTests.swift @@ -38,24 +38,20 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { "Expected the tenth tap to say beta access is unlocked" ) - settings.navigateToAdvancedFeatures(from: self) XCTAssertTrue( settings.switchAccountsRow.waitForExistence(timeout: 10), - "Expected Switch Accounts on Advanced once beta access is unlocked" + "Expected Switch Accounts on Settings once beta access is unlocked" ) - settings.leaveAdvancedFeatures(from: self) - settings.tapVersionFooter(10, from: self) XCTAssertTrue( settings.versionToast("Beta features are hidden again").waitForExistence(timeout: 5), "Expected ten more taps to lock beta access again" ) - settings.navigateToAdvancedFeatures(from: self) XCTAssertTrue( settings.accessKeyRow.waitForExistence(timeout: 10), - "Expected Advanced to be on screen" + "Expected Settings to be on screen" ) XCTAssertFalse( settings.switchAccountsRow.exists, @@ -63,7 +59,7 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { ) } - /// Beta Features is reached through Advanced, one level past what the unlock + /// Beta Features is reached from Settings, one level past what the unlock /// test above visits. Confirms the Contracts section — new rows this repo /// didn't have before — renders once developer access is on. func testBetaFeatures_showsContractVersionsAfterUnlock() throws { @@ -79,8 +75,7 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { "Expected the tenth tap to say beta access is unlocked" ) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(app.buttons["Beta Features"]) + scrollUpToAndTap(app.buttons["Beta Features"], in: settings.scrollView) // Row wraps its content in a Button, so the combined accessibility // element carries the Button trait despite having no tap action. @@ -91,8 +86,6 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { XCTAssertTrue(flipcash2Row.exists) waitAndTap(app.navigationBars["Beta Features"].buttons.firstMatch) - settings.leaveAdvancedFeatures(from: self) - settings.tapVersionFooter(10, from: self) XCTAssertTrue( settings.versionToast("Beta features are hidden again").waitForExistence(timeout: 5), @@ -107,10 +100,7 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { /// itself says nothing until it is tapped, and a tap is the thing under /// test. private func lockBetaAccessIfUnlocked(_ settings: SettingsUIScreen) { - settings.navigateToAdvancedFeatures(from: self) let isUnlocked = settings.switchAccountsRow.waitForExistence(timeout: 5) - settings.leaveAdvancedFeatures(from: self) - guard isUnlocked else { return } settings.tapVersionFooter(10, from: self) diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift index 7be60b3f8..58b06a668 100644 --- a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift @@ -95,10 +95,9 @@ final class BlockUnblockSmokeTests: BaseUITestCase { "Expected '\(name)' tip conversation to disappear from the Chat tab after blocking" ) - // MARK: Unblock from You › My Account › Blocked. + // MARK: Unblock from You › Settings › Blocked. settings.open(from: self) - settings.navigateToMyAccount(from: self) - waitAndTap(settings.blockedRow) + scrollUpToAndTap(settings.blockedRow, in: settings.scrollView) blocked.assertLoaded(from: self) let blockedRow = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", name)).firstMatch @@ -144,8 +143,8 @@ final class BlockUnblockSmokeTests: BaseUITestCase { let you = app.tabBars.buttons["You"].firstMatch guard you.waitForExistence(timeout: 30) else { return } you.tap() - guard scrollTo(settings.myAccountRow, in: settings.scrollView) else { return } - settings.myAccountRow.tap() + guard settings.gear.waitForExistence(timeout: 30) else { return } + settings.gear.tap() guard scrollTo(settings.blockedRow, in: settings.scrollView) else { return } settings.blockedRow.tap() diff --git a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift index 9401e86d4..e63ff58d3 100644 --- a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift @@ -25,16 +25,15 @@ final class BlockedUsersSmokeTests: BaseUITestCase { /// Wallet → You → My Account → Blocked lands on the Blocked list and it /// loads its state (empty or populated) without hanging. - func testBlockedUsers_reachableFromMyAccount() { + func testBlockedUsers_reachableFromSettings() { let settings = SettingsUIScreen(app: app) let blocked = BlockedUsersUIScreen(app: app) assertMainScreenReached() settings.open(from: self) - settings.navigateToMyAccount(from: self) - waitAndTap(settings.blockedRow) + scrollUpToAndTap(settings.blockedRow, in: settings.scrollView) blocked.assertLoaded(from: self) } diff --git a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift index 9b17c9816..66cfbeeaa 100644 --- a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift +++ b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift @@ -38,7 +38,7 @@ final class DisplayNameSmokeTests: BaseUITestCase { createFreshAccount() // MARK: The card is there, with its actions. - settings.open(from: self) + settings.openYouTab(from: self) XCTAssertTrue( app.buttons["you-share-button"].waitForExistence(timeout: 30), "Expected the tip card's Share action on a named account. On screen: [\(visibleText())]" @@ -52,8 +52,8 @@ final class DisplayNameSmokeTests: BaseUITestCase { "Expected the card itself, not the name-less setup prompt" ) - // MARK: Change the name from My Account. - settings.navigateToMyAccount(from: self) + // MARK: Change the name from Settings. + waitAndTap(settings.gear) waitAndTap(settings.displayNameRow) let save = app.buttons["profile-name-next-button"] diff --git a/FlipcashUITests/Smoke/LoginSmokeTests.swift b/FlipcashUITests/Smoke/LoginSmokeTests.swift index 369031399..073d2525c 100644 --- a/FlipcashUITests/Smoke/LoginSmokeTests.swift +++ b/FlipcashUITests/Smoke/LoginSmokeTests.swift @@ -24,11 +24,11 @@ final class LoginSmokeTests: BaseUITestCase { // Verify we're on the main screen assertMainScreenReached() - // Settings lives on the You tab now: You → Advanced → Log Out. The - // settings rows sit under the tip card, so scroll them into view. + // Settings is behind the gear on the You tab: You → gear → Log Out. + // Log Out sits near the foot of the list, so scroll it into view. waitAndTap(app.tabBars.buttons["You"].firstMatch) - scrollUpToAndTap(app.buttons["Advanced"], in: app.scrollViews.firstMatch) - waitAndTap(app.buttons["Log Out"]) + waitAndTap(app.buttons["you-settings"]) + scrollUpToAndTap(app.buttons["Log Out"], in: app.scrollViews.firstMatch) // Confirmation dialog — scoped to the dialog container let dialog = app.otherElements["Are You Sure You Want To Log Out?"] diff --git a/FlipcashUITests/Support/Screens/SettingsScreen.swift b/FlipcashUITests/Support/Screens/SettingsScreen.swift index e24c53fdd..512cc8786 100644 --- a/FlipcashUITests/Support/Screens/SettingsScreen.swift +++ b/FlipcashUITests/Support/Screens/SettingsScreen.swift @@ -7,10 +7,9 @@ import XCTest /// Page object for the settings list and its sub-screens. /// -/// The tab-bar UI has no Settings sheet: `YouScreen` renders the list inline -/// under the tip card, so "open settings" is the You tab plus a scroll. Only -/// **My Account** and **Advanced** live here now — Add Money and Withdraw Money -/// moved to the Wallet tab's tiles, on `WalletScreen`. +/// Settings is pushed from the gear on the You tab, so "open settings" is the +/// You tab plus the gear. Add Money and Withdraw Money live on the Wallet tab's +/// tiles, on `WalletScreen`. @MainActor struct SettingsUIScreen { @@ -22,25 +21,26 @@ struct SettingsUIScreen { // MARK: - Elements - /// The You tab's scrolling content, the container the rows are scrolled in. + /// The Settings list's scrolling content, the container the rows are scrolled in. var scrollView: XCUIElement { app.scrollViews.firstMatch } - var myAccountRow: XCUIElement { app.buttons["My Account"] } - var advancedFeaturesRow: XCUIElement { app.buttons["Advanced"] } + /// The gear on the You tab that pushes Settings. + var gear: XCUIElement { app.buttons["you-settings"] } + var accessKeyRow: XCUIElement { app.buttons["Access Key"] } var applicationLogsRow: XCUIElement { app.buttons["Application Logs"] } - /// The My Account row that opens the display-name editor. + /// The Profile row that opens the display-name editor. var displayNameRow: XCUIElement { app.buttons["Display Name"] } - /// The My Account row that opens the Blocked list. + /// The Privacy row that opens the Blocked list. var blockedRow: XCUIElement { app.buttons["Blocked"] } - /// The Advanced row that opens the account switcher — drawn only once the + /// The Advanced-section row that opens the account switcher — drawn only once the /// version footer has unlocked beta access. var switchAccountsRow: XCUIElement { app.buttons["account-switch-accounts-row"] } - /// The version string at the foot of the You tab, which doubles as the + /// The version string at the foot of Settings, which doubles as the /// beta-access easter egg. var versionFooter: XCUIElement { app.buttons["you-version-footer"] } @@ -63,16 +63,15 @@ struct SettingsUIScreen { // MARK: - Actions - /// Opens the You tab, which hosts the settings list. - func open(from testCase: BaseUITestCase) { + /// Opens the You tab. + func openYouTab(from testCase: BaseUITestCase) { testCase.waitAndTap(app.tabBars.buttons["You"].firstMatch) } - /// Navigates to My Account sub-screen. The rows render below the tip card, - /// so they start off-screen and have to be scrolled to rather than tapped - /// blind. - func navigateToMyAccount(from testCase: BaseUITestCase) { - testCase.scrollUpToAndTap(myAccountRow, in: scrollView) + /// Opens Settings: the You tab, then the gear. + func open(from testCase: BaseUITestCase) { + openYouTab(from: testCase) + testCase.waitAndTap(gear) } /// Taps the version footer `times` times, scrolling it into view first. @@ -83,19 +82,4 @@ struct SettingsUIScreen { versionFooter.tap() } } - - /// Pops the My Account screen back to the You tab. - func leaveMyAccount(from testCase: BaseUITestCase) { - testCase.waitAndTap(app.navigationBars["My Account"].buttons.firstMatch) - } - - /// Navigates to Advanced Features sub-screen. - func navigateToAdvancedFeatures(from testCase: BaseUITestCase) { - testCase.scrollUpToAndTap(advancedFeaturesRow, in: scrollView) - } - - /// Pops the Advanced screen back to the You tab. - func leaveAdvancedFeatures(from testCase: BaseUITestCase) { - testCase.waitAndTap(app.navigationBars["Advanced"].buttons.firstMatch) - } } From 25b8be743221a6e4ab4533fcaaffdb673cc552b4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 07:09:57 -0400 Subject: [PATCH 2/2] chore(settings): drop My Account from test wording, document AccountInfoDetails.Row The UI tests' failure messages and doc comments still named My Account, which this branch removed. Also rewraps a YouScreen doc comment that was reflowed onto one 110-column line. --- Flipcash/Core/Screens/Main/You/YouScreen.swift | 4 ++-- .../Core/Screens/Settings/AccountInfoDetails.swift | 2 ++ FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift | 2 +- FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift | 4 ++-- FlipcashUITests/Smoke/DisplayNameSmokeTests.swift | 10 +++++----- .../Support/Screens/BlockedUsersUIScreen.swift | 2 +- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index eff197d2c..fd00a960e 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -368,8 +368,8 @@ struct YouScreen: View { /// Everything below the card — the part that clears out when the card expands. /// /// The link and the Share/Download pair all address a card that a name-less - /// profile does not have, so they sit out until it does. The checklist shows either way, since naming the - /// profile is one of its chores. + /// profile does not have, so they sit out until it does. The checklist + /// shows either way, since naming the profile is one of its chores. private var pageContent: some View { VStack(spacing: 0) { if profileTutorialState.isVisible { diff --git a/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift b/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift index c80b8f293..aed045bbd 100644 --- a/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift +++ b/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift @@ -12,6 +12,8 @@ import FlipcashCore /// token-account model in FlipcashCore. nonisolated struct AccountInfoDetails { + /// One copyable line on Account Info: what the row shows, and what a tap + /// puts on the clipboard when the two differ. struct Row: Hashable { let title: String let display: String diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift index 58b06a668..c4ecac6c1 100644 --- a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift @@ -134,7 +134,7 @@ final class BlockUnblockSmokeTests: BaseUITestCase { try await super.tearDown() } - /// Navigates You › My Account › Blocked and unblocks `name` if present, + /// Navigates You › Settings › Blocked and unblocks `name` if present, /// tolerating every step so a failed test's teardown stays quiet. It cannot /// reuse `SettingsUIScreen`'s navigation helpers: those assert, and a /// teardown assertion would mask the failure that brought us here. diff --git a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift index e63ff58d3..7179000ca 100644 --- a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift @@ -5,7 +5,7 @@ import XCTest -/// Smoke tests for the Blocked users list reached from the You tab › My Account +/// Smoke tests for the Blocked users list reached from the You tab › Settings /// › Blocked. /// /// **Scope.** These cover the navigation into the list and that the screen loads @@ -23,7 +23,7 @@ final class BlockedUsersSmokeTests: BaseUITestCase { override var requiresAuthentication: Bool { true } - /// Wallet → You → My Account → Blocked lands on the Blocked list and it + /// Wallet → You → Settings → Blocked lands on the Blocked list and it /// loads its state (empty or populated) without hanging. func testBlockedUsers_reachableFromSettings() { let settings = SettingsUIScreen(app: app) diff --git a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift index 66cfbeeaa..51d9338f2 100644 --- a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift +++ b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift @@ -7,7 +7,7 @@ import XCTest /// Covers the display name and the tip card it earns: a freshly registered /// account lands on the You tab with a card it can share, and the name behind -/// that card can be changed from My Account. +/// that card can be changed from Settings. /// /// This replaces the old profile-creation walkthrough (Tips intro → name → /// photo → tipcard), which has no subject left. The name step is mandatory @@ -101,20 +101,20 @@ final class DisplayNameSmokeTests: BaseUITestCase { ) // `ProfileNameScreen(completion: .back)` pops itself only once - // `SetDisplayName` returns, so landing back on My Account is proof the + // `SetDisplayName` returns, so landing back on Settings is proof the // new name was accepted and moderated — a rejection keeps the editor up - // behind a dialog. It pops just the one screen, so this is My Account + // behind a dialog. It pops just the one screen, so this is Settings // rather than the You tab root. XCTAssertTrue( settings.displayNameRow.waitForExistence(timeout: 60), - "Expected the name editor to pop back to My Account once the name saved. On screen: [\(visibleText())]" + "Expected the name editor to pop back to Settings once the name saved. On screen: [\(visibleText())]" ) // MARK: The card survives the rename. // Unwind the last screen by hand: the tab bar stays hidden while the // You tab has a stack, so there is no You button to tap back to. waitAndTap(app.navigationBars.buttons.firstMatch) - assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of My Account") + assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of Settings") XCTAssertTrue( app.buttons["you-share-button"].waitForExistence(timeout: 30), "Expected the tip card still on the You tab after renaming" diff --git a/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift b/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift index f2844d822..d19ceef80 100644 --- a/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift +++ b/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift @@ -5,7 +5,7 @@ import XCTest -/// Page object for `BlockedUsersScreen` — the Settings › My Account › Blocked +/// Page object for `BlockedUsersScreen` — the Settings › Blocked /// list. @MainActor struct BlockedUsersUIScreen {