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..fd00a960e 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,14 +344,32 @@ 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 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 { @@ -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..aed045bbd --- /dev/null +++ b/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift @@ -0,0 +1,63 @@ +// +// 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 { + + /// 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 + 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..c4ecac6c1 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 @@ -135,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. @@ -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..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,18 +23,17 @@ 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_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..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 @@ -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"] @@ -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/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/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 { 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) - } }