From dd2dd8cc282b62c6d92d848b3dce8b7cdac57e2c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 23:00:16 -0400 Subject: [PATCH 01/45] 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 3186981d58b6b52168bf9edfaf41fe2e38488d01 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 07:09:57 -0400 Subject: [PATCH 02/45] 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 { From fffc24c2fdf4a27aedab2e89104a80b4fc947df2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 07:48:45 -0400 Subject: [PATCH 03/45] feat(you): own profile layout; profile card behind Share Rebuild the You tab as the user's own profile: cover banner, avatar, name, handle and bio, a stats card (minimum to chat, date joined), and an Edit Profile button. Share becomes a menu with "Share Profile" and "Show Profile Card"; the card, link row, Share/Download and export move to a full-screen ProfileCardScreen. A "Claim your username" row replaces the username progress card. Add a cover subject to ProfileAvatarStore and extract StartChattingFee from ConversationScreen. Remove TipCardPresentation and the tab-bar expansion state. Exported card files are now named "Chat with ". --- .../Core/Controllers/ProfileAvatarStore.swift | 15 +- .../Conversation/ConversationScreen.swift | 12 +- .../Core/Screens/Main/Home/HomeTabView.swift | 11 +- .../Screens/Main/You/ProfileCardScreen.swift | 175 +++++ .../Core/Screens/Main/You/TipCardExport.swift | 4 +- .../Main/You/TipCardPresentation.swift | 59 -- .../Core/Screens/Main/You/YouScreen.swift | 604 +++++------------- .../Screens/Profile/ProfileCoverBanner.swift | 57 ++ .../Screens/Profile/ProfileCoverFill.swift | 20 + .../Screens/Profile/ProfileHeaderView.swift | 83 +++ .../Screens/Profile/ProfileStatsCard.swift | 50 ++ .../Screens/Profile/StartChattingFee.swift | 41 ++ .../HomeTabBarVisibilityTests.swift | 18 +- FlipcashTests/ProfileAvatarStoreTests.swift | 36 ++ FlipcashTests/ProfileCoverFillTests.swift | 34 + FlipcashTests/StartChattingFeeTests.swift | 57 ++ FlipcashTests/TipCardExportTests.swift | 6 +- .../Smoke/DisplayNameSmokeTests.swift | 20 +- .../Smoke/ProfileCardSmokeTests.swift | 36 ++ .../Smoke/TipCardFullScreenSmokeTests.swift | 62 -- 20 files changed, 781 insertions(+), 619 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift delete mode 100644 Flipcash/Core/Screens/Main/You/TipCardPresentation.swift create mode 100644 Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift create mode 100644 Flipcash/Core/Screens/Profile/ProfileCoverFill.swift create mode 100644 Flipcash/Core/Screens/Profile/ProfileHeaderView.swift create mode 100644 Flipcash/Core/Screens/Profile/ProfileStatsCard.swift create mode 100644 Flipcash/Core/Screens/Profile/StartChattingFee.swift create mode 100644 FlipcashTests/ProfileCoverFillTests.swift create mode 100644 FlipcashTests/StartChattingFeeTests.swift create mode 100644 FlipcashUITests/Smoke/ProfileCardSmokeTests.swift delete mode 100644 FlipcashUITests/Smoke/TipCardFullScreenSmokeTests.swift diff --git a/Flipcash/Core/Controllers/ProfileAvatarStore.swift b/Flipcash/Core/Controllers/ProfileAvatarStore.swift index 68f9d2a15..755d96b96 100644 --- a/Flipcash/Core/Controllers/ProfileAvatarStore.swift +++ b/Flipcash/Core/Controllers/ProfileAvatarStore.swift @@ -23,12 +23,23 @@ final class ProfileAvatarStore { enum AvatarSubject: Hashable, Sendable { case user(UserID) case chat(ConversationID) + /// A user's cover banner. Authorizes like the user's avatar but holds the full-size blob. + case cover(UserID) /// The context this subject's blobs authorize through. var accessContext: BlobAccessContext { switch self { case .user(let userID): .userProfile(userID) case .chat(let conversationID): .chatProfile(conversationID) + case .cover(let userID): .userProfile(userID) + } + } + + /// The blob of `picture` this subject renders: avatars use the thumbnail, a cover the original. + func blobID(for picture: ProfilePicture) -> BlobID { + switch self { + case .user, .chat: picture.thumbnailBlobID + case .cover: picture.blobID } } } @@ -91,12 +102,12 @@ final class ProfileAvatarStore { await load(.user(userID), picture: picture) } - /// Makes a subject's current thumbnail available to ``data(for:)``. + /// Makes a subject's current picture available to ``data(for:)``. /// /// Returns without a round trip when the bytes are already in memory or on disk for that exact /// blob, and joins the fetch already running for that subject rather than starting a second one. func load(_ subject: AvatarSubject, picture: ProfilePicture?) async { - guard let blobID = picture?.thumbnailBlobID else { return } + guard let blobID = picture.map(subject.blobID(for:)) else { return } // A different blob under the same id is a changed picture, not a cache hit. if blobBySubject[subject] == blobID, dataBySubject[subject] != nil { return } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index dcbc59cec..2ba694e49 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -194,13 +194,11 @@ struct ConversationScreen: View { /// the amount the CTA names is the one the amount screen enforces. private var startChattingFee: FiatAmount? { guard !chatExists, let userID = tipCounterpart?.userID else { return nil } - let currency = ratesController.balanceCurrency - return TipFloor.toOpenDM( - recipientFee: session.cachedUserProfile(for: userID)?.minDmChatInitFee, - presets: session.userFlags?.tipPresets(for: currency), - in: currency, - rates: ratesController.cachedRates - )?.displayed + return StartChattingFee.amount( + for: session.cachedUserProfile(for: userID), + session: session, + ratesController: ratesController + ) } /// Whether a chat exists to hold a transcript. An `existing` conversation diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index bbe034dda..365378658 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -66,15 +66,13 @@ struct HomeTabView: View { /// Hide the tab bar whenever the active tab has pushed a screen (e.g. Wallet → /// Currency Info → Give) so that screen owns the full height, while a /// bill/tipcard is up (the app-root overlay owns the screen then, as the v1 - /// bill replaced the bottom bar), and while the You tab's card is expanded. + /// bill replaced the bottom bar). /// Sheets already cover the bar, so only in-tab pushes need handling here. @State private var cardExpansion = WalletCardExpansion() - @State private var tipCardPresentation = TipCardPresentation() private var isTabBarHidden: Bool { selection.hidesTabBar( isWalletCardExpanded: cardExpansion.isExpanded, - isTipCardExpanded: tipCardPresentation.isExpanded, isShowingBill: sessionContainer.session.isShowingBill, hasPushedScreen: selection.pushStack.map { !router[$0].isEmpty } ?? false ) @@ -102,8 +100,6 @@ struct HomeTabView: View { .onChange(of: selection) { _, tab in router.activeTabStack = tab.pushStack if tab == .chat { hasOpenedChat = true } - // Leaving the tab puts the card back (and the brightness with it). - tipCardPresentation.collapse() } .onDisappear { router.activeTabStack = nil } .onChange(of: router.presentedSheet != nil || sessionContainer.session.isShowingBill, initial: true) { _, covered in @@ -330,7 +326,6 @@ struct HomeTabView: View { } case .tipCard: TipCardTab() - .environment(tipCardPresentation) } } } @@ -358,15 +353,13 @@ extension HomeTab { /// hiding on it everywhere left that tab with no bar and no way back. func hidesTabBar( isWalletCardExpanded: Bool, - isTipCardExpanded: Bool, isShowingBill: Bool, hasPushedScreen: Bool ) -> Bool { if isShowingBill || hasPushedScreen { return true } switch self { case .wallet: return isWalletCardExpanded - case .tipCard: return isTipCardExpanded - case .scan, .chat: return false + case .tipCard, .scan, .chat: return false } } } diff --git a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift new file mode 100644 index 000000000..e092b1b46 --- /dev/null +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -0,0 +1,175 @@ +// +// ProfileCardScreen.swift +// Flipcash +// + +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashUI + +/// The user's tip card full screen (Figma node 9277:121410), with the copyable public link and the +/// Share/Download pair under it. Presented over the You tab from the profile's Share menu. +struct ProfileCardScreen: View { + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(\.dismiss) private var dismiss + + /// Warms the share-sheet preview image ahead of the share tap so it never + /// lands on the tap. + @State private var previewCache = TipCodePreviewCache() + @State private var isShowingDownloadOptions = false + + /// The format tapped in the download sheet, held until the sheet is gone so + /// the share sheet has a settled controller to present on. + @State private var pendingDownload: TipCardDownloadFormat? + + /// The brightness to put back on close — set only when this screen raised it. + @State private var previousBrightness: CGFloat? + + /// The card's width, from the full-screen frame (302 of the 402pt frame). + private static let maxCardWidth: CGFloat = 302 + private static let horizontalInset: CGFloat = 20 + + /// Below this a code is hard to scan, so the screen is raised to `boostedBrightness`. + private static let minimumScanBrightness: CGFloat = 0.4 + private static let boostedBrightness: CGFloat = 0.6 + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + if let name = displayName { + TipcardView( + size: cardSize, + name: name, + avatar: nil, + codeData: codeData, + tintOpacity: 0.36, + subtitle: username.map(\.handle) + ) + .padding(.top, 24) + } + + TipCardLinkRow(url: url) + .padding(.top, 32) + + HStack(spacing: 10) { + TipCardActionButton(asset: .shareOS, title: "Share", action: shareTipCard) + .accessibilityIdentifier("you-share-button") + + TipCardActionButton(asset: .fileDownload, title: "Download") { + isShowingDownloadOptions = true + } + .accessibilityIdentifier("you-download-button") + } + .padding(.top, 11) + + closeButton + .padding(.top, 24) + } + .padding(.horizontal, Self.horizontalInset) + } + } + .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { + TipCardDownloadSheet( + onSelect: { pendingDownload = $0; isShowingDownloadOptions = false }, + onCancel: { isShowingDownloadOptions = false } + ) + } + .task(id: profile?.profilePicture?.thumbnailBlobID) { + previewCache.warm(TipCode.Payload(userID: sessionContainer.session.userID)) + } + .onAppear(perform: boostBrightness) + .onDisappear(perform: restoreBrightness) + } + + private var closeButton: some View { + Button { + dismiss() + } label: { + Text("Close") + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .opacity(0.5) + .padding(.vertical, 12) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + .accessibilityIdentifier("profile-card-close") + } + + // MARK: - Content - + + private var profile: Profile? { sessionContainer.session.profile } + + private var displayName: String? { + guard let name = profile?.displayName, !name.isEmpty else { return nil } + return name + } + + private var username: Username? { profile?.username } + + private var codeData: Data { + TipCode.Payload(userID: sessionContainer.session.userID).codeData() + } + + private var url: URL { .tipcard(for: sessionContainer.session.userID, username: username) } + + private var cardSize: CGSize { + let screenWidth = UIApplication.shared.firstWindowScene?.screen.bounds.width ?? Self.maxCardWidth + let width = min(Self.maxCardWidth, screenWidth - Self.horizontalInset * 2) + return CGSize(width: width, height: width * TipcardView.aspectRatio) + } + + // MARK: - Actions - + + private func shareTipCard() { + let item = TipCodeShareItem.profile( + url: url, + displayName: displayName, + preview: previewCache.preview(for: sessionContainer.session.userID) + ) + ShareSheet.present(activityItem: item) { _ in } + } + + /// Exports the format the sheet picked and hands the file to the share + /// sheet, which is where iOS puts "Save to Files" and every other + /// destination. + /// + /// Runs on the download sheet's dismissal rather than its tap: the share + /// sheet presents on the top-most view controller, which is the download + /// sheet itself until that dismissal finishes. + private func exportPendingDownload() { + guard let format = pendingDownload else { return } + pendingDownload = nil + + guard let file = TipCardExport.file(for: format, codeData: codeData, name: displayName) else { + return + } + + ShareSheet.present(activityItems: [file]) { _ in + // The sheet has taken its copy by now, whether or not the user went + // through with it. + TipCardExport.discard(file) + } + } + + // MARK: - Brightness - + + /// The card is a code someone is about to scan, so a dim screen is raised for as long as it is up. + private func boostBrightness() { + guard let screen = UIApplication.shared.firstWindowScene?.screen, + screen.brightness < Self.minimumScanBrightness else { return } + previousBrightness = previousBrightness ?? screen.brightness + screen.brightness = Self.boostedBrightness + } + + private func restoreBrightness() { + guard let previousBrightness, let screen = UIApplication.shared.firstWindowScene?.screen else { return } + screen.brightness = previousBrightness + self.previousBrightness = nil + } +} diff --git a/Flipcash/Core/Screens/Main/You/TipCardExport.swift b/Flipcash/Core/Screens/Main/You/TipCardExport.swift index 84a52dcd2..6d74d8db0 100644 --- a/Flipcash/Core/Screens/Main/You/TipCardExport.swift +++ b/Flipcash/Core/Screens/Main/You/TipCardExport.swift @@ -92,7 +92,7 @@ enum TipCardExport { // MARK: - Naming - - /// `Tip Ada.svg` — the name the share sheet shows and the file the user + /// `Chat with Ada.svg` — the name the share sheet shows and the file the user /// ends up with. Path separators are dropped so a display name can't steer /// where the file is written. private static func filename(for format: TipCardDownloadFormat, name: String?) -> String { @@ -101,7 +101,7 @@ enum TipCardExport { .replacingOccurrences(of: ":", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) - let stem = if let subject, !subject.isEmpty { "Tip \(subject)" } else { "Tip Card" } + let stem = if let subject, !subject.isEmpty { "Chat with \(subject)" } else { "Tip Card" } return "\(stem).\(format.rawValue)" } diff --git a/Flipcash/Core/Screens/Main/You/TipCardPresentation.swift b/Flipcash/Core/Screens/Main/You/TipCardPresentation.swift deleted file mode 100644 index 3ce37888d..000000000 --- a/Flipcash/Core/Screens/Main/You/TipCardPresentation.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// TipCardPresentation.swift -// Flipcash -// - -import UIKit -import Observation - -/// Whether the You tab's tip card is expanded to fill the screen. -/// -/// The card expands inside the page rather than as a presented screen, so the -/// tab bar's usual "hide while a stack is non-empty" rule cannot see it; the You -/// tab reports it here for `HomeTabView` to read. Owns the screen-brightness -/// boost too, since an expanded card is a code someone is about to scan. -@MainActor -@Observable -final class TipCardPresentation { - - private(set) var isExpanded = false - - /// The brightness to put back on collapse — set only when we raised it. - @ObservationIgnored private var previousBrightness: CGFloat? - - /// Below this a code is hard to scan, so the screen is raised to `boosted`. - private static let minimumScanBrightness: CGFloat = 0.4 - private static let boostedBrightness: CGFloat = 0.6 - - func expand() { - guard !isExpanded else { return } - isExpanded = true - boostBrightness() - } - - func collapse() { - guard isExpanded else { return } - isExpanded = false - restoreBrightness() - } - - // MARK: - Brightness - - - private var screen: UIScreen? { - UIApplication.shared.firstWindowScene?.screen - } - - private func boostBrightness() { - guard let screen, screen.brightness < Self.minimumScanBrightness else { return } - if previousBrightness == nil { - previousBrightness = screen.brightness - } - screen.brightness = Self.boostedBrightness - } - - private func restoreBrightness() { - guard let previousBrightness, let screen else { return } - screen.brightness = previousBrightness - self.previousBrightness = nil - } -} diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index fd00a960e..f76799d82 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -8,83 +8,36 @@ import UIKit import FlipcashCore 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 -/// gear that pushes Settings. +/// The "You" tab: the user's own profile — cover, avatar, name, handle and bio — with the stats +/// card, an Edit Profile button, and the finish-your-profile checklist. /// -/// 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 -/// the screen with a close button at the bottom. The page owns that transition — -/// 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 pushes onto the tab's `.you` stack, so it never touches the v1 -/// scanner's Settings sheet. +/// The banner carries two controls: Share, whose menu shares the profile link or presents the +/// tip card full screen (`ProfileCardScreen`), and the gear that pushes Settings. 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 gear is this account's only way to -/// reach Settings, and with it Log Out. +/// A profile with no display name has no card: the page then shows the add-your-name invitation +/// in place of the name block and drops Share, 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(TipCardPresentation.self) private var presentation + @Environment(RatesController.self) private var ratesController /// 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() - @State private var isShowingDownloadOptions = false + @State private var isShowingProfileCard = false - /// The format tapped in the download sheet, held until the sheet is gone so - /// the share sheet has a settled controller to present on. - @State private var pendingDownload: TipCardDownloadFormat? - - /// The balance gate, raised when the card is tapped below the minimum. + /// The balance gate, raised when the claim row is tapped below the minimum. @State private var usernameDialog: DialogItem? /// The top safe-area inset, which sizes the hand-drawn top fade. @State private var safeAreaTop: CGFloat = 0 - /// The card's slot in the page's layout, in global space — where the card - /// sits before it travels to the middle of the screen. - @State private var cardSlotFrame: CGRect = .zero - - /// The live pull on the expanded card, negative upward — the card's travel - /// home, scrubbed by hand until the finger lets go. - @State private var dragTranslation: CGFloat = 0 - - /// The on-screen card width, from the Figma placement: node 9276:4641 draws - /// the You card 241.6 wide. - private static let cardWidth: CGFloat = 242 - /// The expanded card's width, per the full-screen frame (Figma node - /// 9277:121410 — 302 of the 402pt frame). - private static let maxExpandedCardWidth: CGFloat = 302 - /// The page's horizontal inset, also the expanded card's minimum margin. + /// The page's horizontal inset. private static let horizontalInset: CGFloat = 20 - private static let expansion: Animation = .spring(response: 0.45, dampingFraction: 0.85) - - /// Puts a pull that fell short back where it started. Shorter than - /// `expansion` — the card has barely moved, and a long settle reads as a stall. - private static let settle: Animation = .spring(response: 0.3, dampingFraction: 0.85) - - /// How much of the card's travel home a pull has to cover to finish the job. - private static let collapseThreshold: CGFloat = 0.3 - - /// How much of that travel a flick has to project onto instead — a short, - /// fast swipe closes the card even though it never covered the distance. - private static let flickThreshold: CGFloat = 0.75 - - /// The fraction of a downward drag the card follows. Down is where expanding - /// already put the card, so the pull has nowhere to take it and gives only - /// enough to show the drag is being felt. - private static let overdragResistance: CGFloat = 0.25 - - /// The slack the drag recogniser swallows before it fires, handed back so - /// the card picks up from where it stands instead of jumping that distance. - private static let dragActivationSlack: CGFloat = 10 - /// The gap the page keeps between its last row and the tab bar. private static let tabBarGap: CGFloat = 24 @@ -104,74 +57,69 @@ struct YouScreen: View { var body: some View { Background(color: .backgroundMain) { - ZStack { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - cardSection - .padding(.top, 64) - - pageContent - // The expanded card owns the screen: everything under - // it slides down and out — as the tab bar does, which - // `HomeTabView` hides on the same state. Opacity and - // offset (not removal) keep the layout stable so - // nothing reflows on the way back. - // - // Bound to the card being expanded, not to how far a - // pull has taken it back: the card is translucent and - // sits over this content until it is nearly home, so - // anything faded in under way shows through it. - .opacity(isExpanded ? 0 : 1) - .offset(y: isExpanded ? 60 : 0) - .allowsHitTesting(!isExpanded) + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + header + .padding(.top, 8) + + if displayName == nil { + setupPrompt + .padding(.top, 24) + } else { + ProfileStatsCard( + minimumToChat: StartChattingFee.amount( + for: profile, + session: sessionContainer.session, + ratesController: ratesController + ), + joinedAt: profile?.joinedAt + ) + .padding(.top, 24) + + // The Edit Profile slice repoints this to `.editProfile`. + Button("Edit Profile") { + router.push(.settings) + } + .buttonStyle(.filled05) + .padding(.top, 12) + .accessibilityIdentifier("you-edit-profile") + } + + if profileTutorialState.isVisible { + TutorialChecklistCard( + title: "Finish Your Profile", + items: profileTutorialState.items, + onTap: handleProfileTutorialTap + ) + .padding(.top, 32) + .accessibilityIdentifier("you-profile-tutorial-card") } - .padding(.horizontal, Self.horizontalInset) - .padding(.bottom, bottomContentInset) - } - .scrollDisabled(isExpanded) - // The app-wide soft edge effect is drawn from a bar's own - // background, and this tab has no navigation bar at all, so it - // covers the bottom (tab bar) but not the top — what scrolls up - // into the status bar has to be faded by hand. Same treatment as - // `WalletScreen`, the app's other bar-less tab. - .overlay(alignment: .top) { - LinearGradient( - colors: [Color.backgroundMain, Color.backgroundMain.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: safeAreaTop + Self.topFadeLength) - .ignoresSafeArea(edges: .top) - .allowsHitTesting(false) - } - .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) - .opacity(1 - collapseProgress) } + .padding(.horizontal, Self.horizontalInset) + .padding(.bottom, bottomContentInset) + } + // The app-wide soft edge effect is drawn from a bar's own + // background, and this tab has no navigation bar at all, so it + // covers the bottom (tab bar) but not the top — what scrolls up + // into the status bar has to be faded by hand. Same treatment as + // `WalletScreen`, the app's other bar-less tab. + .overlay(alignment: .top) { + LinearGradient( + colors: [Color.backgroundMain, Color.backgroundMain.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: safeAreaTop + Self.topFadeLength) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) } - // On the whole page rather than the card: expanded, the card is the - // page, and a pull that has to land on it exactly is a pull that - // misses. Nothing here scrolls while the card is up, so there is no - // scroll for the drag to fight. - .simultaneousGesture(collapseDrag, isEnabled: isExpanded) + .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { safeAreaTop = $0 } } - .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { - TipCardDownloadSheet( - onSelect: { pendingDownload = $0; isShowingDownloadOptions = false }, - onCancel: { isShowingDownloadOptions = false } - ) + .fullScreenCover(isPresented: $isShowingProfileCard) { + ProfileCardScreen() } .task(id: profilePicture?.thumbnailBlobID) { + await sessionContainer.profileAvatars.load(userID: sessionContainer.session.userID, picture: profilePicture) // There is no card to share, and so no preview worth rendering, until // the profile has a name. guard displayName != nil else { return } @@ -180,62 +128,77 @@ struct YouScreen: View { .dialog(item: $usernameDialog) } - // MARK: - Card - + // MARK: - Header - - /// The card plus its "Full Screen" caption. Tapping either expands the card; - /// tapping the expanded card puts it back. - /// - /// 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 — the gear included, which is the only route to logging out. - @ViewBuilder - private var cardSection: some View { - if let name = displayName { - VStack(spacing: 24) { - // The slot holds the card's place in the page while the card - // itself is offset into the middle of the screen — measuring the - // slot rather than the card keeps the measurement free of the - // offset it feeds. - // - // It keeps its collapsed size throughout: a slot that grew with - // the card would move its own centre mid-flight, and that centre - // is what `cardOffset` measures from, so the card would jump - // against its own travel. Fixed, the card grows about a still - // centre and the page below never reflows. - Color.clear - .frame(width: Self.collapsedCardSize.width, height: Self.collapsedCardSize.height) - .overlay { - TipcardView( - size: Self.drawnCardSize, - name: name, - avatar: nil, - codeData: codeData, - tintOpacity: 0.36, - subtitle: username.map(\.handle) - ) - .scaleEffect(cardScale) - .offset(y: cardOffset) + private var header: some View { + ProfileHeaderView( + userID: sessionContainer.session.userID, + displayName: displayName, + handle: username.map(\.handle), + bio: profile?.bio, + avatarData: sessionContainer.profileAvatars.data(for: sessionContainer.session.userID), + avatarBlurhash: profilePicture?.thumbnailBlurhash, + coverPicture: profile?.coverPicture, + customization: profile?.tipCardCustomization, + statusChip: nil, + bannerActions: { + HStack(spacing: 8) { + if displayName != nil { + shareMenu } - .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { cardSlotFrame = $0 } - - // Sits directly under the card, which covers it until the very - // end of the pull — so it waits for the card to be home rather - // than fading in through it. - Self.caption("Full Screen", pointsUp: false) - .opacity(isExpanded ? 0 : 1) + settingsGear + } + }, + underHandle: { + if shouldPromptForUsername { + Button(action: claimUsername) { + Text("Claim your username ›") + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .accessibilityIdentifier("you-claim-username") + } } - .contentShape(Rectangle()) - .onTapGesture(perform: toggleExpanded) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilityLabel(isExpanded ? "Close tip card" : "View tip card full screen") - .accessibilityIdentifier("you-fullscreen-button") - } else { - setupPrompt + ) + } + + private var shareMenu: some View { + Menu { + Button("Share Profile", action: shareTipCard) + Button("Show Profile Card") { isShowingProfileCard = true } + } label: { + bannerControl(systemName: "square.and.arrow.up") } + .accessibilityLabel("Share") + .accessibilityIdentifier("you-share") } - /// Stands in for the card until the profile has a display name: the card the + private var settingsGear: some View { + Button { + router.push(.settings) + } label: { + bannerControl(systemName: "gearshape") + } + .buttonStyle(.plain) + .accessibilityLabel("Settings") + .accessibilityIdentifier("you-settings") + } + + /// A glyph on a dark disc, legible over any cover picture. + private func bannerControl(systemName: String) -> some View { + Image(systemName: systemName) + .font(.system(size: 16)) + .foregroundStyle(Color.white) + .frame(width: 36, height: 36) + .background(Color.black.opacity(0.4), in: Circle()) + .contentShape(Circle()) + } + + // MARK: - No name - + + /// Stands in for the name block until the profile has a display name: the card the /// user is about to get, blurred out behind the invitation to claim it, so /// the slot shows what is on offer rather than sitting empty. /// @@ -246,7 +209,7 @@ struct YouScreen: View { // A stand-in name, never read: it only has to give the blur a card // shaped like the one the user gets. TipcardView( - size: Self.collapsedCardSize, + size: Self.placeholderCardSize, name: Self.placeholderName, avatar: nil, codeData: codeData, @@ -294,7 +257,7 @@ struct YouScreen: View { } // Held inside the card it sits on, so the copy reads as part of the // card rather than spilling past its edges. - .frame(maxWidth: Self.collapsedCardSize.width - 16) + .frame(maxWidth: Self.placeholderCardSize.width - 16) } } @@ -305,119 +268,20 @@ struct YouScreen: View { /// The placeholder card's outline, matching `TipcardView`'s own corner /// rounding — which it derives from the card's width. private static let placeholderShape = RoundedRectangle( - cornerRadius: collapsedCardSize.width * 0.08, + cornerRadius: placeholderCardSize.width * 0.08, style: .continuous ) - /// The expanded card's collapse control (Figma node 9276:4601): the same - /// caption as "Full Screen", chevron flipped, sitting at the bottom of the - /// screen where the tab bar was. - private var closeButton: some View { - VStack { - Spacer() - - Button(action: toggleExpanded) { - Self.caption("Close", pointsUp: true) - .padding(.vertical, 12) - .padding(.horizontal, 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") - .accessibilityIdentifier("you-close-fullscreen-button") - } - .padding(.bottom, 8) - } - - /// The dimmed caption-and-chevron pair used both to expand the card and to - /// put it back. - private static func caption(_ title: String, pointsUp: Bool) -> some View { - HStack(spacing: 5) { - Text(title) - .font(.appTextSmall) - Image.system(.chevronDown) - .renderingMode(.template) - .frame(width: 16, height: 16) - .rotationEffect(.degrees(pointsUp ? 180 : 0)) - } - .foregroundStyle(Color.textMain) - .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 checklist - /// shows either way, since naming the profile is one of its chores. - private var pageContent: some View { - VStack(spacing: 0) { - if profileTutorialState.isVisible { - TutorialChecklistCard( - title: "Finish Your Profile", - items: profileTutorialState.items, - onTap: handleProfileTutorialTap - ) - .padding(.top, 32) - .accessibilityIdentifier("you-profile-tutorial-card") - - // Fences the checklist off from the block below it — the two are - // unrelated chores that would otherwise read as one list. - Color.rowSeparator - .frame(height: 1) - .padding(.top, 20) - } - - if displayName != nil { - TipCardLinkRow(url: url) - .padding(.top, profileTutorialState.isVisible ? 20 : 70) - - HStack(spacing: 10) { - TipCardActionButton(asset: .shareOS, title: "Share", action: shareTipCard) - .accessibilityIdentifier("you-share-button") - - TipCardActionButton(asset: .fileDownload, title: "Download") { - isShowingDownloadOptions = true - } - .accessibilityIdentifier("you-download-button") - } - .padding(.top, 11) - - if shouldPromptForUsername { - usernameProgressCard - .padding(.top, 11) - .accessibilityIdentifier("you-username-progress-card") - } - } - } - } + /// The placeholder card's size, which gives the blur a card shaped like the one the user gets. + private static let placeholderCardSize = CGSize( + width: 242, + height: 242 * TipcardView.aspectRatio + ) - // MARK: - Username progress - + // MARK: - Username claim - /// The balance's standing against the username minimum. Read once per - /// render and switched on here rather than re-derived at tap time — the - /// card's tappability and this switch's `.proceed` branch already agree - /// on the same value. + /// render and switched on here rather than re-derived at tap time. private var usernameProgress: UsernameGate { usernameGate( session: sessionContainer.session, @@ -425,32 +289,8 @@ struct YouScreen: View { ) } - @ViewBuilder - private var usernameProgressCard: some View { - switch usernameProgress { - case .proceed: - UsernameProgressCard( - state: .complete, - subtitle: "Tap to select your username now", - trailing: nil, - fraction: 1, - action: beginUsernameClaim - ) - case .addMoney(let minimum, let shortfall, let fraction): - UsernameProgressCard( - state: .incomplete, - subtitle: "Get your balance to \(minimum.formatted(minimumFractionDigits: 0)) \(minimum.currency.rawValue.uppercased()) or more to unlock", - trailing: "\(shortfall.formatted(minimumFractionDigits: 0)) \(shortfall.currency.rawValue.uppercased()) to go", - fraction: fraction, - action: { presentBalanceGate(minimum: minimum) } - ) - } - } - // MARK: - Content - - private var isExpanded: Bool { presentation.isExpanded } - private var profile: Profile? { sessionContainer.session.profile } private var profilePicture: ProfilePicture? { profile?.profilePicture } private var profileTutorialState: ProfileTutorialState { .init(profile: profile) } @@ -479,125 +319,8 @@ struct YouScreen: View { private var url: URL { .tipcard(for: sessionContainer.session.userID, username: username) } - // MARK: - Card geometry - - - /// The card's size in the page — also the slot's size at every point in the - /// expansion, so the page's layout is the same expanded as collapsed. - private static let collapsedCardSize = CGSize( - width: cardWidth, - height: cardWidth * TipcardView.aspectRatio - ) - - /// The size the card is always drawn at, whatever it currently measures on - /// screen — the widest it ever gets, so scaling it to size only ever - /// samples the drawing down. - private static let drawnCardSize = CGSize( - width: maxExpandedCardWidth, - height: maxExpandedCardWidth * TipcardView.aspectRatio - ) - - /// The expansion runs as one scale on the drawn card rather than a new size - /// for it to lay itself out at. Laid out, each of the card's metrics is a - /// separate animatable value — and the name's font size is not animatable - /// at all — so the parts arrived at their new sizes at different moments - /// and overlapped mid-flight. Scaled, the card travels as one figure. - private var cardScale: CGFloat { - guard isExpanded else { return Self.cardWidth / Self.drawnCardSize.width } - // Mid-pull the card sits between its two widths, in step with everything - // else the pull drives. - let width = expandedCardWidth + (Self.cardWidth - expandedCardWidth) * collapseProgress - return width / Self.drawnCardSize.width - } - - /// The expanded width: the design's 302, narrowed only if the screen can't - /// hold it inside the page's margins. - private var expandedCardWidth: CGFloat { - guard screenBounds.width > 0 else { return Self.maxExpandedCardWidth } - return min(Self.maxExpandedCardWidth, screenBounds.width - Self.horizontalInset * 2) - } - - /// The whole screen, safe areas included — what the design centres the - /// expanded card in. Read from the window rather than a geometry proxy, - /// which reports the safe-area rect instead; `BillOverlayView` centres the - /// full-screen bill the same way. - private var screenBounds: CGRect { - UIApplication.shared.firstWindowScene?.screen.bounds ?? .zero - } - - /// The gap between the card's slot's centre and the screen's: how far the - /// card moves to sit in the middle of the screen, and so how far a pull has - /// to bring it back. - private var expandedTravel: CGFloat { - guard !cardSlotFrame.isEmpty, !screenBounds.isEmpty else { return 0 } - return screenBounds.midY - cardSlotFrame.midY - } - - /// How far the card currently sits from its slot. - private var cardOffset: CGFloat { - guard isExpanded else { return 0 } - return expandedTravel * (1 - collapseProgress) + overdrag - } - - /// How far a downward drag pushes the card past its expanded resting place. - private var overdrag: CGFloat { - max(0, dragTranslation) * Self.overdragResistance - } - - /// How far the pull has taken the card back toward its slot: 0 where - /// expanding left it, 1 once it is home. The card's place, its size and the - /// close control all read it, so they run backwards under the finger and - /// hold wherever the finger holds. - private var collapseProgress: CGFloat { - guard isExpanded, dragTranslation < 0, expandedTravel > 0 else { return 0 } - return min(1, -dragTranslation / expandedTravel) - } - // MARK: - Actions - - /// Puts the expanded card back by pulling it toward its slot — upward, - /// where the card came from and where the "Close" chevron points. The card - /// shrinks back along its travel under the finger, the close control fading - /// with it, and lets go once the pull has covered enough of the way home or - /// flicked hard enough to have got there. A downward pull, which has nowhere - /// to take the card, is met with resistance instead. - private var collapseDrag: some Gesture { - DragGesture(minimumDistance: Self.dragActivationSlack) - .onChanged { drag in - let translation = drag.translation.height - dragTranslation = translation < 0 - ? min(0, translation + Self.dragActivationSlack) - : max(0, translation - Self.dragActivationSlack) - } - .onEnded(endCollapseDrag) - } - - private func endCollapseDrag(_ drag: DragGesture.Value) { - let travel = expandedTravel - let closes = travel > 0 && ( - -dragTranslation >= travel * Self.collapseThreshold - || -drag.predictedEndTranslation.height >= travel * Self.flickThreshold - ) - - withAnimation(closes ? Self.expansion : Self.settle) { - // Cleared in the same transaction as the collapse it may carry, so - // the card carries on from where the finger left it either way. - dragTranslation = 0 - if closes { - presentation.collapse() - } - } - } - - private func toggleExpanded() { - withAnimation(Self.expansion) { - if isExpanded { - presentation.collapse() - } else { - presentation.expand() - } - } - } - private func shareTipCard() { let item = TipCodeShareItem.profile( url: url, @@ -607,35 +330,17 @@ struct YouScreen: View { ShareSheet.present(activityItem: item) { _ in } } - /// Exports the format the sheet picked and hands the file to the share - /// sheet, which is where iOS puts "Save to Files" and every other - /// destination. - /// - /// Runs on the download sheet's dismissal rather than its tap: the share - /// sheet presents on the top-most view controller, which is the download - /// sheet itself until that dismissal finishes. - private func exportPendingDownload() { - guard let format = pendingDownload else { return } - pendingDownload = nil - - guard let file = TipCardExport.file(for: format, codeData: codeData, name: displayName) else { - return - } - - ShareSheet.present(activityItems: [file]) { _ in - // The sheet has taken its copy by now, whether or not the user went - // through with it. - TipCardExport.discard(file) + /// Opens the claim screen once the balance clears the minimum, and the + /// balance gate until then. + private func claimUsername() { + switch usernameProgress { + case .proceed: + router.push(.username(username)) + case .addMoney(let minimum, _, _): + presentBalanceGate(minimum: minimum) } } - /// Opens the claim screen. Only ever wired to `usernameProgressCard`'s tap - /// in its `.complete` state, which already means `usernameProgress` is - /// `.proceed` — there is nothing left here to branch on. - private func beginUsernameClaim() { - router.push(.username(username)) - } - private func handleProfileTutorialTap(_ item: ProfileTutorialItem) { switch item { case .displayName: @@ -647,9 +352,8 @@ struct YouScreen: View { } } - /// The `.incomplete` card's tap: names the minimum and offers the way to - /// meet it. The card draws the shortfall but not the reason for it, so the - /// dialog is where the squatting rule gets stated. + /// Names the minimum and offers the way to meet it. The row draws no + /// shortfall, so the dialog is where the squatting rule gets stated. private func presentBalanceGate(minimum: FiatAmount) { usernameDialog = .usernameMinimumBalance(minimum: minimum) { router.presentAddMoney(.general, source: .usernameShortfall) diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift new file mode 100644 index 000000000..846f37548 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -0,0 +1,57 @@ +// +// ProfileCoverBanner.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// A profile's 3:1 cover: the picture once it has loaded, the blurhash while it does, and the +/// profile-card colour when there is no picture. +struct ProfileCoverBanner: View { + + @Environment(SessionContainer.self) private var sessionContainer + + let userID: UserID + let coverPicture: ProfilePicture? + let customization: TipCardCustomization? + @ViewBuilder let actions: () -> Actions + + private static var aspectRatio: CGFloat { 3 } + + var body: some View { + Color.clear + .aspectRatio(Self.aspectRatio, contentMode: .fit) + .overlay { cover } + .overlay(alignment: .topTrailing) { + actions() + .padding(8) + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .task(id: coverPicture?.blobID) { + await sessionContainer.profileAvatars.load(.cover(userID), picture: coverPicture) + } + .accessibilityIdentifier("profile-cover") + } + + @ViewBuilder + private var cover: some View { + if let coverPicture { + let data = sessionContainer.profileAvatars.data(for: .cover(userID)) + if let data, let image = ContactAvatarCache.shared.image(forKey: "cover-\(coverPicture.blobID)", data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else if let preview = BlurHashCache.shared.image(for: coverPicture.thumbnailBlurhash) { + Image(uiImage: preview) + .resizable() + .scaledToFill() + } else { + ProfileCoverFill.color(for: customization) + } + } else { + ProfileCoverFill.color(for: customization) + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift b/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift new file mode 100644 index 000000000..baf76140b --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift @@ -0,0 +1,20 @@ +// +// ProfileCoverFill.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The solid colour a cover banner shows when the profile has no cover picture. +enum ProfileCoverFill { + + /// The card tone used when a profile carries no colour, or one that doesn't parse. + static let fallback = Color.backgroundRow + + /// The customization's colour, or ``fallback`` when it is absent or not a `#RRGGBB` hex. + static func color(for customization: TipCardCustomization?) -> Color { + customization.flatMap { Color(hex: $0.colorHex) } ?? fallback + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift new file mode 100644 index 000000000..50cc54705 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -0,0 +1,83 @@ +// +// ProfileHeaderView.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// A profile's top block: cover, avatar overlapping it, name, handle and bio. +/// +/// A nil `displayName` leaves the name block out, for a profile that has not named itself yet. +struct ProfileHeaderView: View { + + let userID: UserID + let displayName: String? + let handle: String? + let bio: String? + let avatarData: Data? + let avatarBlurhash: String? + let coverPicture: ProfilePicture? + let customization: TipCardCustomization? + let statusChip: AnyView? + @ViewBuilder let bannerActions: () -> BannerActions + @ViewBuilder let underHandle: () -> UnderHandle + + private static var avatarSize: CGFloat { 88 } + + var body: some View { + VStack(spacing: 0) { + ProfileCoverBanner( + userID: userID, + coverPicture: coverPicture, + customization: customization, + actions: bannerActions + ) + + ContactAvatarView( + id: userID.uuidString, + displayName: displayName ?? "", + imageData: avatarData, + blurhash: avatarBlurhash, + size: Self.avatarSize + ) + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 4) } + .padding(.top, -Self.avatarSize / 2) + + if let displayName { + VStack(spacing: 4) { + Text(displayName) + .font(.appDisplaySmall) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.center) + .accessibilityIdentifier("profile-name") + + if let handle { + Text(handle) + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .accessibilityIdentifier("profile-handle") + } + + underHandle() + } + .padding(.top, 12) + + if let statusChip { + statusChip + .padding(.top, 8) + } + + if let bio, !bio.isEmpty { + Text(bio) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.center) + .padding(.top, 12) + .accessibilityIdentifier("profile-bio") + } + } + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift new file mode 100644 index 000000000..39ee5766b --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift @@ -0,0 +1,50 @@ +// +// ProfileStatsCard.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The two figures under a profile header: what it costs to start a chat, and when the user joined. +struct ProfileStatsCard: View { + + let minimumToChat: FiatAmount? + let joinedAt: Date? + + var body: some View { + HStack(spacing: 0) { + stat( + title: "Minimum to Chat", + value: minimumToChat?.formatted() ?? "—", + identifier: "profile-stat-minimum" + ) + + Color.rowSeparator + .frame(width: 1, height: 32) + + stat( + title: "Date Joined", + value: joinedAt?.formatted(.dateTime.month(.abbreviated).year()) ?? "—", + identifier: "profile-stat-joined" + ) + } + .padding(.vertical, 16) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private func stat(title: String, value: String, identifier: String) -> some View { + VStack(spacing: 4) { + Text(value) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Text(title) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityIdentifier(identifier) + } +} diff --git a/Flipcash/Core/Screens/Profile/StartChattingFee.swift b/Flipcash/Core/Screens/Profile/StartChattingFee.swift new file mode 100644 index 000000000..b92e3c4d5 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/StartChattingFee.swift @@ -0,0 +1,41 @@ +// +// StartChattingFee.swift +// Flipcash +// + +import FlipcashCore + +/// What it costs to open a chat with a user, as their profile and the conversation CTA name it. +/// +/// Derived from the same inputs as `SendAmountViewModel.tipFloor(in:)`, so the amount shown is the +/// one the amount screen enforces. +enum StartChattingFee { + + /// The fee `recipientFee` charges, restated in `currency` and rounded up, falling back to the + /// regional preset minimum. Nil when neither is known. + static func amount( + recipientFee: FiatAmount?, + presets: UserFlags.TipPresets?, + currency: CurrencyCode, + rates: [CurrencyCode: Rate] + ) -> FiatAmount? { + TipFloor.toOpenDM( + recipientFee: recipientFee, + presets: presets, + in: currency, + rates: rates + )?.displayed + } + + /// The fee for `profile`, in the user's balance currency. + @MainActor + static func amount(for profile: Profile?, session: Session, ratesController: RatesController) -> FiatAmount? { + let currency = ratesController.balanceCurrency + return amount( + recipientFee: profile?.minDmChatInitFee, + presets: session.userFlags?.tipPresets(for: currency), + currency: currency, + rates: ratesController.cachedRates + ) + } +} diff --git a/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift b/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift index 8cbe82b52..476298cbe 100644 --- a/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift +++ b/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift @@ -14,32 +14,20 @@ struct HomeTabBarVisibilityTests { func walletCard_hidesOnlyOnWallet(tab: HomeTab) { let hidden = tab.hidesTabBar( isWalletCardExpanded: true, - isTipCardExpanded: false, isShowingBill: false, hasPushedScreen: false ) #expect(hidden == (tab == .wallet)) } - @Test("an expanded tip card hides the bar only on the You tab", arguments: HomeTab.allCases) - func tipCard_hidesOnlyOnYou(tab: HomeTab) { - let hidden = tab.hidesTabBar( - isWalletCardExpanded: false, - isTipCardExpanded: true, - isShowingBill: false, - hasPushedScreen: false - ) - #expect(hidden == (tab == .tipCard)) - } - @Test("a bill or a pushed screen hides the bar on every tab", arguments: HomeTab.allCases) func billOrPush_hidesEverywhere(tab: HomeTab) { - #expect(tab.hidesTabBar(isWalletCardExpanded: false, isTipCardExpanded: false, isShowingBill: true, hasPushedScreen: false)) - #expect(tab.hidesTabBar(isWalletCardExpanded: false, isTipCardExpanded: false, isShowingBill: false, hasPushedScreen: true)) + #expect(tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: true, hasPushedScreen: false)) + #expect(tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: false, hasPushedScreen: true)) } @Test("nothing open shows the bar", arguments: HomeTab.allCases) func idle_shows(tab: HomeTab) { - #expect(!tab.hidesTabBar(isWalletCardExpanded: false, isTipCardExpanded: false, isShowingBill: false, hasPushedScreen: false)) + #expect(!tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: false, hasPushedScreen: false)) } } diff --git a/FlipcashTests/ProfileAvatarStoreTests.swift b/FlipcashTests/ProfileAvatarStoreTests.swift index 2c91ce136..9f6076e46 100644 --- a/FlipcashTests/ProfileAvatarStoreTests.swift +++ b/FlipcashTests/ProfileAvatarStoreTests.swift @@ -12,6 +12,8 @@ import FlipcashCore @Suite("ProfileAvatarStore") struct ProfileAvatarStoreTests { + private typealias AvatarSubject = ProfileAvatarStore.AvatarSubject + /// A suspension the test releases by hand, so a fetch can be held mid-flight while the callers /// around it are cancelled. Not cancellation-aware: the fetch under test checks cancellation /// itself, the way `URLSession` does. @@ -123,6 +125,40 @@ struct ProfileAvatarStoreTests { #expect(fetches.value == 1) } + /// The banner shows the original, not the avatar-sized rendition the same picture carries. + @Test("A cover loads the original blob, independently of the avatar") + func coverLoadsOriginalAndCachesApartFromAvatar() async { + let userID = UUID() + let picture = ProfilePicture( + blobID: BlobID(uuid: UUID()), + thumbnailBlobID: BlobID(uuid: UUID()) + ) + let minted = Counter() + var mintedBlobs: [BlobID] = [] + + let store = ProfileAvatarStore( + cache: Self.cache(), + mintURL: { blobID, _ in + mintedBlobs.append(blobID) + minted.increment() + return URL(string: "https://example.test/\(blobID)")! + }, + fetch: { _ in Self.bytes } + ) + + await store.load(.cover(userID), picture: picture) + + #expect(mintedBlobs == [picture.blobID]) + #expect(store.data(for: AvatarSubject.cover(userID)) == Self.bytes) + #expect(store.data(for: userID) == nil) + + await store.load(.user(userID), picture: picture) + + #expect(mintedBlobs == [picture.blobID, picture.thumbnailBlobID]) + #expect(store.data(for: userID) == Self.bytes) + #expect(minted.value == 2) + } + /// Bytes already on disk are the common case at launch, and paying for a round trip there would /// put a blurhash on screen for the length of one. @Test("Bytes already on disk need no fetch") diff --git a/FlipcashTests/ProfileCoverFillTests.swift b/FlipcashTests/ProfileCoverFillTests.swift new file mode 100644 index 000000000..d9f416401 --- /dev/null +++ b/FlipcashTests/ProfileCoverFillTests.swift @@ -0,0 +1,34 @@ +// +// ProfileCoverFillTests.swift +// FlipcashTests +// + +import SwiftUI +import Testing +import FlipcashCore +import FlipcashUI +@testable import Flipcash + +@MainActor +@Suite("ProfileCoverFill") +struct ProfileCoverFillTests { + + @Test("A valid hex gives the parsed colour") + func validHexIsParsed() { + let color = ProfileCoverFill.color(for: TipCardCustomization(colorHex: "#19191A")) + + #expect(color == Color(hex: "#19191A")) + } + + @Test("An unparsable hex gives the default") + func invalidHexFallsBack() { + let color = ProfileCoverFill.color(for: TipCardCustomization(colorHex: "not-a-colour")) + + #expect(color == ProfileCoverFill.fallback) + } + + @Test("No customization gives the default") + func nilFallsBack() { + #expect(ProfileCoverFill.color(for: nil) == ProfileCoverFill.fallback) + } +} diff --git a/FlipcashTests/StartChattingFeeTests.swift b/FlipcashTests/StartChattingFeeTests.swift new file mode 100644 index 000000000..b39617e50 --- /dev/null +++ b/FlipcashTests/StartChattingFeeTests.swift @@ -0,0 +1,57 @@ +// +// StartChattingFeeTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("StartChattingFee") +struct StartChattingFeeTests { + + private let presets = UserFlags.TipPresets( + currency: .usd, + minimum: Decimal(string: "0.25")!, + low: 5, + medium: 10, + high: 20 + ) + + private let rates: [CurrencyCode: Rate] = [ + .usd: Rate(fx: 1, currency: .usd), + .cad: Rate(fx: Decimal(string: "1.37")!, currency: .cad), + ] + + /// 0.33 USD is 0.4521 CAD: half-up would show 0.45, which sits under the fee. + @Test("A fee shown in another currency rounds up, as the amount screen enforces") + func feeInOtherCurrencyRoundsUp() { + let fee = FiatAmount(value: Decimal(string: "0.33")!, currency: .usd) + + let amount = StartChattingFee.amount( + recipientFee: fee, + presets: presets, + currency: .cad, + rates: rates + ) + + #expect(amount == FiatAmount(value: Decimal(string: "0.46")!, currency: .cad)) + #expect(amount == TipFloor.toOpenDM(recipientFee: fee, presets: presets, in: .cad, rates: rates)?.displayed) + } + + @Test("No fee falls back to the preset minimum") + func nilFeeFallsBackToPresetMinimum() { + let amount = StartChattingFee.amount(recipientFee: nil, presets: presets, currency: .usd, rates: rates) + + #expect(amount == FiatAmount(value: Decimal(string: "0.25")!, currency: .usd)) + } + + @Test("No fee and no presets gives nothing") + func nilFeeAndNilPresetsIsNil() { + let amount = StartChattingFee.amount(recipientFee: nil, presets: nil, currency: .usd, rates: rates) + + #expect(amount == nil) + } +} diff --git a/FlipcashTests/TipCardExportTests.swift b/FlipcashTests/TipCardExportTests.swift index 26a8c197a..fa419c618 100644 --- a/FlipcashTests/TipCardExportTests.swift +++ b/FlipcashTests/TipCardExportTests.swift @@ -18,7 +18,7 @@ struct TipCardExportTests { let url = try #require(TipCardExport.file(for: .svg, codeData: .placeholder35, name: "Ada")) defer { TipCardExport.discard(url) } - #expect(url.lastPathComponent == "Tip Ada.svg") + #expect(url.lastPathComponent == "Chat with Ada.svg") let svg = try String(contentsOf: url, encoding: .utf8) #expect(svg.hasPrefix("