From 77f36f5f73c74293fd9a8f10eeb010ae4c2429b1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 11:52:58 -0400 Subject: [PATCH 1/4] feat(onboarding): land new accounts on Chat A new account finishing onboarding now opens on Chat, the tab every other login already opens on, instead of Money (node 12527:81350). HomeTab.postOnboarding existed only so onboarding could pass .wallet to completeLogin, so it goes, along with the landingTab parameter and SessionAuthenticator.landingTab that carried it. HomeTabView always opens on HomeTab.initial. --- Flipcash/Core/ContainerScreen.swift | 2 +- Flipcash/Core/Screens/Main/Home/HomeTab.swift | 9 +++------ .../Core/Screens/Main/Home/HomeTabView.swift | 8 ++++---- .../Screens/Onboarding/OnboardingViewModel.swift | 2 +- Flipcash/Core/Session/SessionAuthenticator.swift | 8 ++------ .../Navigation/HomeTabLandingTests.swift | 16 ++++++++++++++++ .../Smoke/CreateAccountSmokeTests.swift | 8 ++++++++ 7 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 FlipcashTests/Navigation/HomeTabLandingTests.swift diff --git a/Flipcash/Core/ContainerScreen.swift b/Flipcash/Core/ContainerScreen.swift index 89fcec982..d3742c128 100644 --- a/Flipcash/Core/ContainerScreen.swift +++ b/Flipcash/Core/ContainerScreen.swift @@ -34,7 +34,7 @@ struct ContainerScreen: View { case .loggedIn(let sessionContainer): ZStack { - HomeTabView(initialTab: sessionAuthenticator.landingTab) + HomeTabView() .modifier(OnrampHostModifier()) // Bills / tipcards render at the app root, over the tab bar, diff --git a/Flipcash/Core/Screens/Main/Home/HomeTab.swift b/Flipcash/Core/Screens/Main/Home/HomeTab.swift index b83d13c2e..fee80787c 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTab.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTab.swift @@ -7,7 +7,7 @@ import SwiftUI /// The tabs of the v2 tab-bar UI, in display order (left → right). Chat sits /// second, beside the scanner that feeds it. Declaration order is the bar's -/// order, not the launch tab; see ``initial`` and ``postOnboarding``. Matches Android's +/// order, not the launch tab; see ``initial``. Matches Android's /// `NavBarButton.tabs`. /// /// Icons are the Figma tab-bar glyphs (`Nav*` template imagesets, from the same @@ -20,13 +20,10 @@ enum HomeTab: Int, CaseIterable, Identifiable, Hashable { var id: Int { rawValue } - /// The tab selected on cold launch and after signing in to an existing - /// account. + /// The tab selected on cold launch, after signing in to an existing + /// account, and when onboarding finishes creating a new one. static let initial: HomeTab = .chat - /// The tab selected when onboarding finishes creating a new account. - static let postOnboarding: HomeTab = .wallet - /// The asset-catalog name of the tab's template glyph. Selected tabs use a /// filled glyph and the rest an outline, per the tab bar spec. /// diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 793bc6547..46dc91d38 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -7,7 +7,7 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The post-login root. Hosts the four tabs, opens on the tab it is given, and owns the +/// The post-login root. Hosts the four tabs, opens on `HomeTab.initial`, and owns the /// app-level `router.rootSheet` host so `router.present(_:)` works from any tab. /// /// On iOS 26 the tabs live in a native `TabView`, which renders the system @@ -37,9 +37,9 @@ struct HomeTabView: View { /// than by either bar, because both bars want the same download. @State private var profilePhoto = TabBarProfilePhoto() - init(initialTab: HomeTab = .initial) { - _selection = State(initialValue: initialTab) - _hasOpenedChat = State(initialValue: initialTab == .chat) + init() { + _selection = State(initialValue: HomeTab.initial) + _hasOpenedChat = State(initialValue: HomeTab.initial == .chat) // Color the tab glyphs white in both states, as iOS 27 already does; the // outline-vs-filled glyph carries the selection. The iOS 26 tab bar diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index 5018ed492..762e20a2c 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -238,7 +238,7 @@ class OnboardingViewModel { return } - sessionAuthenticator.completeLogin(with: initializedAccount, landingTab: .postOnboarding) + sessionAuthenticator.completeLogin(with: initializedAccount) Analytics.onboardingCompleted() } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 4a636f161..7db40cd8d 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -33,9 +33,6 @@ final class SessionAuthenticator { /// The top-level authentication state driving the view hierarchy. private(set) var state: AuthenticationState = .migrating - /// The tab the home screen opens on for the current login. - private(set) var landingTab: HomeTab = .initial - /// Feature flags available before authentication (e.g. minimum build version). private(set) var unauthenticatedUserFlags: UnauthenticatedUserFlags? @@ -464,8 +461,8 @@ final class SessionAuthenticator { } } - /// Builds the session for `initializedAccount` and shows the home screen on `landingTab`. - func completeLogin(with initializedAccount: InitializedAccount, landingTab: HomeTab = .initial) { + /// Builds the session for `initializedAccount` and shows the home screen on `HomeTab.initial`. + func completeLogin(with initializedAccount: InitializedAccount) { logger.info("completeLogin", metadata: ["owner": "\(initializedAccount.keyAccount.ownerPublicKey)"]) let session = createSessionContainer( @@ -473,7 +470,6 @@ final class SessionAuthenticator { initializedAccount: initializedAccount ) - self.landingTab = landingTab state = .loggedIn(session) session.quickActionsController.configure() UserDefaults.wasLoggedIn = true diff --git a/FlipcashTests/Navigation/HomeTabLandingTests.swift b/FlipcashTests/Navigation/HomeTabLandingTests.swift new file mode 100644 index 000000000..1af7b5b72 --- /dev/null +++ b/FlipcashTests/Navigation/HomeTabLandingTests.swift @@ -0,0 +1,16 @@ +// +// HomeTabLandingTests.swift +// FlipcashTests +// + +import Testing +@testable import Flipcash + +@Suite("Home tab landing") @MainActor +struct HomeTabLandingTests { + + @Test("Home opens on Chat after every login") + func homeOpensOnChat() { + #expect(HomeTab.initial == .chat) + } +} diff --git a/FlipcashUITests/Smoke/CreateAccountSmokeTests.swift b/FlipcashUITests/Smoke/CreateAccountSmokeTests.swift index 5fa22aad4..b524f7674 100644 --- a/FlipcashUITests/Smoke/CreateAccountSmokeTests.swift +++ b/FlipcashUITests/Smoke/CreateAccountSmokeTests.swift @@ -32,6 +32,10 @@ final class CreateAccountSmokeTests: BaseUITestCase { assertMainScreenReached( "Expected to reach the main screen after account creation via Save to Photos" ) + XCTAssertTrue( + app.tabBars.buttons["Chat"].firstMatch.isSelected, + "Expected Chat to be the selected tab after onboarding" + ) } func testCreateAccount_wroteDownInstead() { @@ -52,5 +56,9 @@ final class CreateAccountSmokeTests: BaseUITestCase { assertMainScreenReached( "Expected to reach the main screen after account creation via Wrote Down" ) + XCTAssertTrue( + app.tabBars.buttons["Chat"].firstMatch.isSelected, + "Expected Chat to be the selected tab after onboarding" + ) } } From 734b836fb52e7831c97f5414085c9aa8ffe8f6f8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 11:59:57 -0400 Subject: [PATCH 2/4] feat(wallet): show only the Add Money row to new users The new-user wallet loses the "Send Your First Tip" checklist and keeps one row, "Add Money / Add money to your account", which opens Add Money (node 12527:81782). It hides once the user has added money, the same point the wallet tiles appear, so the row and the tiles never show together. AddMoneyRowState replaces NewUserTutorialState. Tipping no longer feeds the wallet, so Session.hasEverTipped and Database.hasEverTipped are deleted along with TutorialChecklistCard. --- .../Screens/Main/Home/AddMoneyRowState.swift | 29 +++++ .../Core/Screens/Main/Home/HomeTabView.swift | 2 +- .../Screens/Main/Home/NewUserTutorial.swift | 77 ------------- .../Core/Screens/Main/Home/WalletScreen.swift | 77 +++++++------ Flipcash/Core/Session/Session.swift | 5 - Flipcash/Views/TutorialChecklistCard.swift | 109 ------------------ .../FlipcashStore/Database+Onboarding.swift | 37 +----- FlipcashTests/AddMoneyRowStateTests.swift | 61 ++++++++++ .../Database/Database+OnboardingTests.swift | 46 -------- FlipcashTests/HistoryControllerTests.swift | 2 +- FlipcashTests/NewUserTutorialStateTests.swift | 72 ------------ .../Smoke/DiscoverCurrenciesSmokeTests.swift | 2 +- .../Support/Screens/AddMoneyStartScreen.swift | 2 +- .../Support/Screens/WalletScreen.swift | 5 +- 14 files changed, 139 insertions(+), 387 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Home/AddMoneyRowState.swift delete mode 100644 Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift delete mode 100644 Flipcash/Views/TutorialChecklistCard.swift create mode 100644 FlipcashTests/AddMoneyRowStateTests.swift delete mode 100644 FlipcashTests/NewUserTutorialStateTests.swift diff --git a/Flipcash/Core/Screens/Main/Home/AddMoneyRowState.swift b/Flipcash/Core/Screens/Main/Home/AddMoneyRowState.swift new file mode 100644 index 000000000..23e887065 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/AddMoneyRowState.swift @@ -0,0 +1,29 @@ +// +// AddMoneyRowState.swift +// Flipcash +// + +/// Whether the wallet draws its Add Money row. +/// +/// `hasAddedMoney` reads a *local* cache that starts empty on every fresh login — +/// switching accounts included — so it cannot be trusted until the history has +/// been reconciled with the server at least once. Without that wait, an +/// established account signing in is greeted with the row for as long as its +/// history takes to arrive. Mirrors Android's `WalletViewModel.State.isAwaitingActivity`. +struct AddMoneyRowState: Equatable { + + let hasAddedMoney: Bool + let historySyncState: HistoryController.SyncState + /// Whether the local history already holds activity. Stored rows short-circuit + /// the wait: history that is already here is nothing to mistake for a new + /// account. + let hasStoredActivity: Bool + + var isAwaitingHistory: Bool { + historySyncState == .unknown && !hasStoredActivity + } + + /// Withheld while `hasAddedMoney` is still unknown — the row is never the + /// thing the wallet guesses at. + var isVisible: Bool { !isAwaitingHistory && !hasAddedMoney } +} diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 46dc91d38..3b4f1bf6e 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -400,7 +400,7 @@ struct HomeTabView: View { Color.backgroundMain } case .wallet: - WalletScreen(onScanTipCard: { selection = .scan }) + WalletScreen() .environment(cardExpansion) case .chat: // Building the conversation list costs 300–550 ms, so when the app diff --git a/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift b/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift deleted file mode 100644 index b867fae2d..000000000 --- a/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// NewUserTutorial.swift -// Flipcash -// - -import SwiftUI -import FlipcashUI - -/// A step in the wallet's new-user tutorial (Figma frame 8966:1516, ported from -/// Android's `TutorialItem`). -nonisolated enum TutorialItem: TutorialItemPresentable { - case addMoney(isCompleted: Bool) - case scanTipCard(isCompleted: Bool) - - var id: String { title } - - var isCompleted: Bool { - switch self { - case .addMoney(let done), .scanTipCard(let done): return done - } - } - - var title: String { - switch self { - case .addMoney: return "Add Money" - case .scanTipCard: return "Scan a Profile Card" - } - } - - var subtitle: String { - switch self { - case .addMoney: return "Add money to your account" - case .scanTipCard: return "Send your first message" - } - } - - var icon: Image { - switch self { - case .addMoney: return Image(systemName: "plus.circle") - case .scanTipCard: return Image("NavTipCard") - } - } -} - -/// Whether the wallet draws the new-user tutorial, and with which milestones -/// checked off. -/// -/// Both milestones are reads of a *local* cache that starts empty on every fresh -/// login — switching accounts included — so neither can be trusted until the -/// history has been reconciled with the server at least once. Without that wait, -/// an established account signing in is greeted with the new-user tutorial for -/// as long as its history takes to arrive. Mirrors Android's -/// `WalletViewModel.State.isAwaitingActivity`. -struct NewUserTutorialState: Equatable { - - let hasAddedMoney: Bool - let hasTipped: Bool - let historySyncState: HistoryController.SyncState - /// Whether the local history already holds activity. Stored rows short-circuit - /// the wait: history that is already here is nothing to mistake for a new - /// account. - let hasStoredActivity: Bool - - var items: [TutorialItem] { - [.addMoney(isCompleted: hasAddedMoney), .scanTipCard(isCompleted: hasTipped)] - } - - var isComplete: Bool { hasAddedMoney && hasTipped } - - var isAwaitingHistory: Bool { - historySyncState == .unknown && !hasStoredActivity - } - - /// Withheld while the milestones are still unknown — the tutorial is never - /// the thing the wallet guesses at. - var isVisible: Bool { !isAwaitingHistory && !isComplete } -} diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index 0ce55eec4..f4ba78837 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -15,11 +15,8 @@ struct WalletScreen: View { @Environment(SessionContainer.self) private var sessionContainer - /// Invoked by the tutorial's "Scan a Profile Card" step to switch to the Scan tab. - let onScanTipCard: () -> Void - var body: some View { - WalletScreenContent(sessionContainer: sessionContainer, onScanTipCard: onScanTipCard) + WalletScreenContent(sessionContainer: sessionContainer) } } @@ -33,13 +30,10 @@ private struct WalletScreenContent: View { @Environment(WalletCardExpansion.self) private var cardExpansion let session: Session - let onScanTipCard: () -> Void - @State private var cards: [TokenCardData] = [] @State private var total: ExchangedFiat @State private var appreciation: (amount: FiatAmount, isPositive: Bool) @State private var hasAddedMoney: Bool - @State private var hasTipped: Bool /// The unified recent-activity preview (newest first). @State private var recentActivities: [Activity] @@ -139,9 +133,8 @@ private struct WalletScreenContent: View { return Self.tabBarGap + HomeTabView.legacyPillClearance } - init(sessionContainer: SessionContainer, onScanTipCard: @escaping () -> Void) { + init(sessionContainer: SessionContainer) { self.session = sessionContainer.session - self.onScanTipCard = onScanTipCard let rate = sessionContainer.ratesController.rateForBalanceCurrency() // Seed synchronously so the first render shows real balances, not an // empty-state flash. @@ -157,16 +150,14 @@ private struct WalletScreenContent: View { _arrivalProgress = State(initialValue: landing?.isNewToken == true ? 0 : 1) _appreciation = State(initialValue: seed.appreciation) _hasAddedMoney = State(initialValue: sessionContainer.session.hasEverAddedMoney()) - _hasTipped = State(initialValue: sessionContainer.session.hasEverTipped()) _recentActivities = State(initialValue: sessionContainer.session.recentActivities(limit: Self.recentPreviewCount)) } private var rate: Rate { ratesController.rateForBalanceCurrency() } - private var tutorialState: NewUserTutorialState { - NewUserTutorialState( + private var addMoneyRowState: AddMoneyRowState { + AddMoneyRowState( hasAddedMoney: hasAddedMoney, - hasTipped: hasTipped, historySyncState: historyController.syncState, hasStoredActivity: !recentActivities.isEmpty ) @@ -247,7 +238,7 @@ private struct WalletScreenContent: View { .onChange(of: rate) { _, _ in refresh() } // Activities land in the DB independently of a balance change (e.g. // a synced history page), and both the recent preview and the - // onboarding milestones are derived from them — so reload both on + // add-money milestone are derived from them — so reload both on // any DB change rather than only when a balance moves. .onReceive(NotificationCenter.default.publisher(for: .databaseDidChange)) { _ in reloadHistoryState() @@ -496,7 +487,7 @@ private struct WalletScreenContent: View { private var walletContent: some View { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { - // Header + tutorial scroll off first; the card stack then reaches the + // Header + Add Money row scroll off first; the card stack then reaches the // top and collapses into its back card. The stack measures the // scroll itself (via `.visualEffect`), so no height plumbing here. VStack(spacing: 0) { @@ -506,13 +497,9 @@ private struct WalletScreenContent: View { .padding(.top, headerTopPadding) .padding(.bottom, 44) - if tutorialState.isVisible { - TutorialChecklistCard( - title: "Send Your First Tip", - items: tutorialState.items, - onTap: handleTutorialTap - ) - .padding(.bottom, 20) + if addMoneyRowState.isVisible { + addMoneyRow + .padding(.bottom, 20) } } // The stack is deliberately outside the fade: its cards carry @@ -542,8 +529,8 @@ private struct WalletScreenContent: View { } // Returning users (already funded) get the tile shortcuts - // below the activity; new users use the tutorial's own steps - // instead. + // below the activity; new users get the Add Money row + // above instead. if hasAddedMoney { walletTiles .padding(.top, 24) @@ -621,9 +608,7 @@ private struct WalletScreenContent: View { /// A tile-style entry point: icon top-leading, label pinned bottom-leading, /// inside a translucent rounded card. They tile two-up per row. /// - /// `identifier` is what tests target: several of these titles also appear on - /// the new-user tutorial rows above, where a completed milestone renders - /// disabled, so matching on the label alone can resolve to an untappable row. + /// `identifier` is what tests target, so they do not depend on the label. private func walletTile(icon: TileGlyph, title: String, identifier: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(alignment: .leading, spacing: 0) { @@ -671,13 +656,34 @@ private struct WalletScreenContent: View { .padding(.top, 24) } - private func handleTutorialTap(_ item: TutorialItem) { - switch item { - case .addMoney: + private var addMoneyRow: some View { + Button { router.presentAddMoney(.general, source: .balance) - case .scanTipCard: - onScanTipCard() + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "plus.circle") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(Color.textSecondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Add Money") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Text("Add money to your account") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .background(Color.backgroundRow) + .clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityIdentifier("wallet-add-money-row") } // MARK: - Deposit @@ -748,24 +754,21 @@ private struct WalletScreenContent: View { } /// Re-reads the history-derived state: the recent-activity preview and the - /// two onboarding milestones. All three come from the activity and message - /// tables, which a history sync writes without touching balances — so they + /// add-money milestone. Both come from the activity table, which a history + /// sync writes without touching balances — so they /// cannot ride on ``refresh()``'s balance trigger, and a deposit's activity /// row routinely lands after the balance rise that preceded it. private func reloadHistoryState() { let activities = session.recentActivities(limit: Self.recentPreviewCount) let addedMoney = session.hasEverAddedMoney() - let tipped = session.hasEverTipped() guard activities != recentActivities || addedMoney != hasAddedMoney - || tipped != hasTipped else { return } withAnimation(.default) { recentActivities = activities hasAddedMoney = addedMoney - hasTipped = tipped } } diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 895cb226a..4bef56029 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -236,11 +236,6 @@ class Session { updateableBalances.value.contains { $0.quarks > 0 } } - /// Whether the caller has ever sent a tip — the "scan a tip card" milestone. - func hasEverTipped() -> Bool { - (try? database.hasEverTipped(selfUserID: userID)) ?? false - } - /// The mint's bill-customization colors (the `#RRGGBB` palette users pick in /// the currency creator), used to paint its token card. Empty when the mint /// has no stored metadata or no customization. diff --git a/Flipcash/Views/TutorialChecklistCard.swift b/Flipcash/Views/TutorialChecklistCard.swift deleted file mode 100644 index 690572a95..000000000 --- a/Flipcash/Views/TutorialChecklistCard.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// TutorialChecklistCard.swift -// Flipcash -// - -import SwiftUI -import FlipcashUI - -/// A step in a checklist card: what it says, whether it is done, and the glyph -/// it carries while it is not. -/// -/// `nonisolated` so its `Equatable`/`Identifiable` refinements stay nonisolated -/// too — this target defaults declarations to the main actor, which would -/// otherwise make every conformance an isolated one. -nonisolated protocol TutorialItemPresentable: Identifiable, Equatable { - var title: String { get } - var subtitle: String { get } - var isCompleted: Bool { get } - /// The glyph for an unfinished step. A finished one is drawn by the card as - /// a checkmark, so conformers never describe the completed state. - /// - /// Main-actor isolated because `Image.asset(_:)` is, and the glyph is only - /// ever read while the card renders. - @MainActor var icon: Image { get } -} - -/// A titled checklist of tappable steps with a completed count — the Wallet's -/// "Send Your First Tip" and the You tab's "Finish Your Profile" (Figma node -/// 9544:18140). Completed steps show a green check, dim, and stop responding to -/// taps. -struct TutorialChecklistCard: View { - - let title: String - let items: [Item] - let onTap: (Item) -> Void - - private var completedCount: Int { items.filter(\.isCompleted).count } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text(title) - .font(.appBarButton) - .foregroundStyle(Color.textMain) - Spacer() - Text("\(completedCount)/\(items.count)") - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - } - .padding(16) - - VStack(spacing: 16) { - ForEach(items) { item in - row(item) - } - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.backgroundRow) - .clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) - } - } - - private func row(_ item: Item) -> some View { - Button { - onTap(item) - } label: { - HStack(alignment: .top, spacing: 12) { - icon(item) - .frame(width: 24, height: 24) - - VStack(alignment: .leading, spacing: 4) { - Text(item.title) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .frame(maxWidth: .infinity, alignment: .leading) - Text(item.subtitle) - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - .opacity(item.isCompleted ? 0.38 : 1) - - Image.system(.chevronRight) - .font(.appTextMedium) - .foregroundStyle(Color.textSecondary) - .frame(maxHeight: .infinity, alignment: .center) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(item.isCompleted) - } - - @ViewBuilder private func icon(_ item: Item) -> some View { - if item.isCompleted { - Image(systemName: "checkmark.circle.fill") - .resizable() - .scaledToFit() - .foregroundStyle(Color.Sentiment.positive) - } else { - item.icon - .renderingMode(.template) - .resizable() - .scaledToFit() - .foregroundStyle(Color.textSecondary) - } - } -} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift b/FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift index 6f256e67b..5e86793c2 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Onboarding.swift @@ -7,9 +7,9 @@ import Foundation import FlipcashCore import SQLite -/// Durable-history checks backing the new-user tutorial. These read the -/// event history (not the current balance), so a milestone stays complete even -/// after the user later spends the balance — mirroring Android. +/// The durable-history check behind the wallet's new-user Add Money row. It +/// reads the event history (not the current balance), so the row stays hidden +/// after the user later spends the balance. nonisolated extension Database { /// Activity kinds that bring money *in* from outside the user's own @@ -33,35 +33,4 @@ nonisolated extension Database { ) ) != nil } - - /// True once the caller has *sent* a tip — the "scanned a tip card" - /// milestone. Either source proves it, because neither is complete alone: - /// the activity feed re-syncs in full from the server on every login, while - /// chat messages sync lazily per conversation, so an account signed into on - /// a fresh database has activity long before it has any messages. - public func hasEverTipped(selfUserID: UserID) throws -> Bool { - try hasEverSentTipActivity() || hasEverSentTipMessage(selfUserID: selfUserID) - } - - /// A completed outgoing direct send — the activity a tip writes to the - /// history feed. `cashLink` is excluded: an unclaimed link is a send to - /// nobody, not a tip given. - private func hasEverSentTipActivity() throws -> Bool { - let a = ActivityTable() - return try reader.pluck( - a.table.filter( - a.kind == Activity.Kind.gave.rawValue && a.state == Activity.State.completed.rawValue - ) - ) != nil - } - - /// A tip this user sent in a chat. Received tips don't count, so it's scoped - /// to `senderId == selfUserID`. - private func hasEverSentTipMessage(selfUserID: UserID) throws -> Bool { - let m = ConversationMessageTable() - // cashAction 1 == tipped (see ConversationMessageTable.cashAction). - return try reader.pluck( - m.table.filter(m.cashAction == 1 && m.senderId == selfUserID) - ) != nil - } } diff --git a/FlipcashTests/AddMoneyRowStateTests.swift b/FlipcashTests/AddMoneyRowStateTests.swift new file mode 100644 index 000000000..882553a22 --- /dev/null +++ b/FlipcashTests/AddMoneyRowStateTests.swift @@ -0,0 +1,61 @@ +// +// AddMoneyRowStateTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import Flipcash + +/// The wallet must not mistake a cold local cache for a brand-new account: on +/// every fresh login — switching accounts included — the history reads empty +/// until the first sync lands, and drawing that verdict greets an established +/// user with the Add Money row. +@Suite +@MainActor +struct AddMoneyRowStateTests { + + private static func state( + addedMoney: Bool = false, + sync: HistoryController.SyncState = .synced, + storedActivity: Bool = false + ) -> AddMoneyRowState { + AddMoneyRowState( + hasAddedMoney: addedMoney, + historySyncState: sync, + hasStoredActivity: storedActivity + ) + } + + @Test("The row shows once history has synced and no money was ever added") + func shownAfterSync() { + let state = Self.state(sync: .synced) + #expect(!state.isAwaitingHistory) + #expect(state.isVisible) + } + + @Test("The row is hidden once money has been added") + func hiddenAfterMoneyAdded() { + #expect(!Self.state(addedMoney: true, sync: .synced).isVisible) + #expect(!Self.state(addedMoney: true, sync: .unknown).isVisible) + } + + @Test("The row is withheld while history is unsynced and nothing is stored") + func withheldBeforeFirstSync() { + let state = Self.state(sync: .unknown) + #expect(state.isAwaitingHistory) + #expect(!state.isVisible) + } + + @Test("Stored activity short-circuits the wait") + func storedActivityShortCircuits() { + let state = Self.state(sync: .unknown, storedActivity: true) + #expect(!state.isAwaitingHistory) + #expect(state.isVisible) + } + + @Test("An unreachable server ends the wait rather than hiding the row forever") + func unavailableEndsTheWait() { + #expect(Self.state(sync: .unavailable).isVisible) + } +} diff --git a/FlipcashTests/Database/Database+OnboardingTests.swift b/FlipcashTests/Database/Database+OnboardingTests.swift index 34f5e8bd4..3ab33684e 100644 --- a/FlipcashTests/Database/Database+OnboardingTests.swift +++ b/FlipcashTests/Database/Database+OnboardingTests.swift @@ -90,50 +90,4 @@ struct DatabaseOnboardingTests { #expect(try db.hasEverAddedMoney() == true) } - - // MARK: - hasEverTipped - - @Test("hasEverTipped is true for a completed outgoing tip in the activity feed") - func hasEverTipped_gaveActivity() throws { - let (db, url) = try Database.makeTemp() - defer { Database.removeTemp(at: url) } - - #expect(try db.hasEverTipped(selfUserID: UUID()) == false) - - try db.insertActivities(activities: [Self.makeActivity(kind: .gave)]) - - // Chat messages sync lazily per conversation, so an account signed into - // on a fresh database has none — the activity feed is what carries the - // milestone across an account switch. - #expect(try db.hasEverTipped(selfUserID: UUID()) == true) - } - - @Test("hasEverTipped ignores a pending tip until it completes") - func hasEverTipped_pendingDoesNotCount() throws { - let (db, url) = try Database.makeTemp() - defer { Database.removeTemp(at: url) } - - try db.insertActivities(activities: [ - Self.makeActivity(kind: .gave, state: .pending), - ]) - #expect(try db.hasEverTipped(selfUserID: UUID()) == false) - - try db.insertActivities(activities: [ - Self.makeActivity(kind: .gave, state: .completed), - ]) - #expect(try db.hasEverTipped(selfUserID: UUID()) == true) - } - - @Test( - "hasEverTipped ignores kinds that aren't a tip given", - arguments: [Activity.Kind.received, .deposited, .bought, .withdrew, .cashLink, .sold, .swapped] - ) - func hasEverTipped_nonTipKinds(kind: Activity.Kind) throws { - let (db, url) = try Database.makeTemp() - defer { Database.removeTemp(at: url) } - - try db.insertActivities(activities: [Self.makeActivity(kind: kind)]) - - #expect(try db.hasEverTipped(selfUserID: UUID()) == false) - } } diff --git a/FlipcashTests/HistoryControllerTests.swift b/FlipcashTests/HistoryControllerTests.swift index 26d7eaedf..4031548ad 100644 --- a/FlipcashTests/HistoryControllerTests.swift +++ b/FlipcashTests/HistoryControllerTests.swift @@ -60,7 +60,7 @@ struct HistoryControllerTests { } /// An empty local cache means "we haven't looked yet" until a sync says - /// otherwise — the wallet withholds its new-user tutorial on this. + /// otherwise — the wallet withholds its new-user Add Money row on this. @Test("Initial syncState is .unknown before the first sync") func initialSyncStateIsUnknown() throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/NewUserTutorialStateTests.swift b/FlipcashTests/NewUserTutorialStateTests.swift deleted file mode 100644 index 3bee2aff6..000000000 --- a/FlipcashTests/NewUserTutorialStateTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// NewUserTutorialStateTests.swift -// FlipcashTests -// - -import Foundation -import Testing -@testable import Flipcash - -/// The wallet must not mistake a cold local cache for a brand-new account: on -/// every fresh login — switching accounts included — the history and both -/// onboarding milestones read empty until the first sync lands, and drawing that -/// verdict greets an established user with the new-user tutorial. -@Suite -@MainActor -struct NewUserTutorialStateTests { - - private static func state( - addedMoney: Bool = false, - tipped: Bool = false, - sync: HistoryController.SyncState = .synced, - storedActivity: Bool = false - ) -> NewUserTutorialState { - NewUserTutorialState( - hasAddedMoney: addedMoney, - hasTipped: tipped, - historySyncState: sync, - hasStoredActivity: storedActivity - ) - } - - @Test("The tutorial is withheld while the history is still unsynced") - func withheldBeforeFirstSync() { - let state = Self.state(sync: .unknown) - #expect(state.isAwaitingHistory) - #expect(!state.isVisible) - } - - @Test("The tutorial shows once the history has synced and a milestone is outstanding") - func shownAfterSync() { - let state = Self.state(addedMoney: true, sync: .synced) - #expect(!state.isAwaitingHistory) - #expect(state.isVisible) - } - - @Test("An unreachable server ends the wait rather than hiding the tutorial forever") - func unavailableEndsTheWait() { - let state = Self.state(sync: .unavailable) - #expect(!state.isAwaitingHistory) - #expect(state.isVisible) - } - - @Test("Stored activity short-circuits the wait — there is nothing to mistake for a new account") - func storedActivityShortCircuits() { - let state = Self.state(addedMoney: true, sync: .unknown, storedActivity: true) - #expect(!state.isAwaitingHistory) - #expect(state.isVisible) - } - - @Test("A completed tutorial is never drawn, synced or not") - func completedIsHidden() { - #expect(!Self.state(addedMoney: true, tipped: true, sync: .synced).isVisible) - #expect(!Self.state(addedMoney: true, tipped: true, sync: .unknown).isVisible) - } - - @Test("Milestones drive the step completion the tutorial renders") - func itemsCarryMilestones() { - let state = Self.state(addedMoney: true, tipped: false) - #expect(state.items == [.addMoney(isCompleted: true), .scanTipCard(isCompleted: false)]) - #expect(!state.isComplete) - } -} diff --git a/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift b/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift index c729802bf..46ae5c3b0 100644 --- a/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift +++ b/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift @@ -18,7 +18,7 @@ import XCTest /// **Fixture.** Needs the standing `FLIPCASH_UI_TEST_ACCESS_KEY` account rather /// than a fresh one: `WalletScreen` draws the tiles only when /// `session.hasEverAddedMoney()` is true, and gives an unfunded account the -/// new-user tutorial in their place. The test only navigates, so it leaves the +/// Add Money row in their place. The test only navigates, so it leaves the /// account untouched. final class DiscoverCurrenciesSmokeTests: BaseUITestCase { diff --git a/FlipcashUITests/Support/Screens/AddMoneyStartScreen.swift b/FlipcashUITests/Support/Screens/AddMoneyStartScreen.swift index 52691104d..d41df33a3 100644 --- a/FlipcashUITests/Support/Screens/AddMoneyStartScreen.swift +++ b/FlipcashUITests/Support/Screens/AddMoneyStartScreen.swift @@ -24,7 +24,7 @@ struct AddMoneyStartScreen { /// The "No Balance Yet" primary CTA, scoped to the dialog. An unscoped /// "Add Money" match can resolve to the wallet behind the dialog, which has - /// both a tile and a new-user tutorial row under that label. + /// an "Add Money" button of its own. var addMoneyButton: XCUIElement { app.otherElements["No Balance Yet"].buttons["Add Money"] } diff --git a/FlipcashUITests/Support/Screens/WalletScreen.swift b/FlipcashUITests/Support/Screens/WalletScreen.swift index 643091db8..c18043292 100644 --- a/FlipcashUITests/Support/Screens/WalletScreen.swift +++ b/FlipcashUITests/Support/Screens/WalletScreen.swift @@ -45,9 +45,8 @@ struct WalletScreen { /// The balance header button that shows the flag + total amount + chevron. var balanceHeader: XCUIElement { app.buttons["balance-header"] } - /// The "Add Money" tile. Matched by identifier, not label: the new-user - /// tutorial above it renders an "Add Money" row too, and on a funded account - /// that row is completed and therefore disabled. + /// The "Add Money" tile. Matched by identifier, which is stable + /// across label changes. var addMoneyTile: XCUIElement { app.buttons["wallet-tile-add-money"] } /// The "Withdraw Money" tile, which pushes the currency picker onto the From 0b6fe037cd2fc5009bb86f81add8ea4ef427dfa4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 12:17:12 -0400 Subject: [PATCH 3/4] feat(profile): prompt new users to complete their profile Frame 12 (node 12527:81838) changes the You tab for a new account: - "Claim your username" is gone from the header. The username can still be set from Edit Profile. - The edit button reads "Complete Your Profile" on the system blue fill until the profile has a photo, a cover photo and a non-blank bio, then goes back to the gray "Edit Profile". Username and favorites do not count. - The featured-groups section is titled "Favorite Community Chats" everywhere. On the own profile it shows "None yet" when empty, but only once the favorites read has succeeded (or there is no username to read with), so an account with favorites never flashes the empty line. --- .../Core/Screens/Main/You/YouScreen.swift | 84 ++++++----------- .../Core/Screens/Profile/FeaturedGroups.swift | 5 ++ .../Profile/FeaturedGroupsSection.swift | 44 +++++++-- .../Screens/Profile/ProfileHeaderView.swift | 12 ++- .../Sources/FlipcashCore/Models/Profile.swift | 9 ++ FlipcashTests/ProfileCompletenessTests.swift | 90 +++++++++++++++++++ 6 files changed, 176 insertions(+), 68 deletions(-) create mode 100644 FlipcashTests/ProfileCompletenessTests.swift diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 4b455d807..9537ed1c4 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -31,9 +31,6 @@ struct YouScreen: View { /// lands on the tap; keyed by user. @State private var previewCache = TipCodePreviewCache() - /// The balance gate, raised when the claim row is tapped below the minimum. - @State private var usernameDialog: DialogItem? - @State private var isSharingToChats = false /// The gap the page keeps between its last row and the tab bar. @@ -71,7 +68,13 @@ struct YouScreen: View { ) .padding(.top, 19) - FeaturedGroupsSection(groups: sessionContainer.featuredGroups.groups) { id in + FeaturedGroupsSection( + groups: sessionContainer.featuredGroups.groups, + showsEmptyState: FeaturedGroupsSection.ownProfileShowsEmptyState( + hasUsername: username != nil, + isLoaded: sessionContainer.featuredGroups.isLoaded + ) + ) { id in if let group = sessionContainer.featuredGroups.group(withID: id) { conversationController.hold(group) } @@ -106,7 +109,6 @@ struct YouScreen: View { guard let username else { return } await sessionContainer.featuredGroups.load(username: username) } - .dialog(item: $usernameDialog) .fullScreenCover(isPresented: $isSharingToChats) { ShareToChatsSheet( subject: .user( @@ -142,7 +144,10 @@ struct YouScreen: View { bannerControls: { EmptyView() }, coverAccessory: { EmptyView() }, rowActions: { - ProfileEditCapsule { + ProfileEditCapsule( + title: editButton.title, + isAccented: editButton.isAccented + ) { router.push(.editProfile) } .accessibilityIdentifier("you-edit-profile") @@ -151,21 +156,14 @@ struct YouScreen: View { shareButton } }, - underSubtitle: { - if shouldPromptForUsername { - Button(action: claimUsername) { - Text("Claim your username ›") - .font(.appTextSmall) - .foregroundStyle(Color.textMain) - .frame(minHeight: 22) - } - .buttonStyle(.plain) - .accessibilityIdentifier("you-claim-username") - } - } + underSubtitle: { EmptyView() } ) } + private var editButton: EditProfileButtonState { + EditProfileButtonState(isProfileComplete: profile?.isComplete == true) + } + private var shareButton: some View { ProfileActionCircle(image: Image.asset(.shareOS)) { isSharingToChats = true @@ -278,17 +276,6 @@ struct YouScreen: View { height: 242 * TipcardView.aspectRatio ) - // 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. - private var usernameProgress: UsernameGate { - usernameGate( - session: sessionContainer.session, - minimum: sessionContainer.session.userFlags?.usernameMinBalance - ) - } - // MARK: - Content - private var profile: Profile? { sessionContainer.session.profile } @@ -301,39 +288,22 @@ struct YouScreen: View { private var username: Username? { profile?.username } - /// Whether to offer the handle: the user has none, or the one they have was - /// auto-assigned by the server and they haven't picked their own. Re-derived - /// from the profile on every render, so a handle that disappears across a - /// refresh puts the offer back on its own. - private var shouldPromptForUsername: Bool { - usernameNeedsClaim( - username: username, - isAutoAssigned: profile?.isUsernameAutoAssigned == true - ) - } - private var codeData: Data { TipCode.Payload(userID: sessionContainer.session.userID).codeData() } +} - // MARK: - Actions - +/// The label and style of the You tab's edit button for a profile's completeness. +struct EditProfileButtonState: Equatable { - /// 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) - } - } + /// "Complete Your Profile" until the profile is complete, then "Edit Profile". + let title: String + /// Whether the button takes the accent fill rather than today's gray capsule. + let isAccented: Bool - /// 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: ratesController.displayCurrency.amount(minimum)) { - router.presentAddMoney(.general, source: .usernameShortfall) - } + /// The state for a profile that is, or is not yet, complete (see `Profile.isComplete`). + init(isProfileComplete: Bool) { + title = isProfileComplete ? "Edit Profile" : "Complete Your Profile" + isAccented = !isProfileComplete } } diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroups.swift b/Flipcash/Core/Screens/Profile/FeaturedGroups.swift index 708e48eb4..7a7d4c49a 100644 --- a/Flipcash/Core/Screens/Profile/FeaturedGroups.swift +++ b/Flipcash/Core/Screens/Profile/FeaturedGroups.swift @@ -22,6 +22,9 @@ final class FeaturedGroups { private(set) var groups: [Conversation] = [] + /// Whether the list has been read or saved, so an empty `groups` means none rather than not yet. + private(set) var isLoaded = false + @ObservationIgnored private let fetching: (Username) async throws -> [Conversation] @ObservationIgnored private let fillingCovers: ([Conversation]) async -> [ConversationID: Conversation] @@ -50,6 +53,7 @@ final class FeaturedGroups { let full = await fillingCovers(groups) // Keyed by id rather than replaced wholesale, so a save landing meanwhile stands. groups = groups.map { full[$0.id] ?? $0 } + isLoaded = true return true } catch { guard !Task.isCancelled else { return false } @@ -62,5 +66,6 @@ final class FeaturedGroups { /// Takes the list a save returned, which is the server's word on what is now featured. func replace(with groups: [Conversation]) { self.groups = groups + isLoaded = true } } diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift index 0d20bffed..6ebdaf263 100644 --- a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -7,26 +7,55 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The "Favorite Public Groups" section on a profile, one row per group in the owner's order. Draws -/// nothing when the list is empty. +/// The "Favorite Community Chats" section on a profile, one row per group in the owner's order. With +/// no groups it draws nothing, or a "None yet" line when `showsEmptyState` is set. struct FeaturedGroupsSection: View { + /// The section heading, on every profile that shows it. + static let title = "Favorite Community Chats" + /// The line shown in place of the list when the owner has no groups. + static let emptyText = "None yet" + let groups: [Conversation] + /// Whether an empty list still draws the section with the "None yet" line; own profile only. + var showsEmptyState: Bool = false let onTap: (ConversationID) -> Void + /// Whether the owner's own profile can say "None yet": only once their favorites are known to be + /// empty, so an account that has favorites never flashes it while the read is in flight or failed. + /// With no username there is nothing to read, so the list is known empty straight away. + static func ownProfileShowsEmptyState(hasUsername: Bool, isLoaded: Bool) -> Bool { + !hasUsername || isLoaded + } + + /// Whether the section renders for `groupCount` groups. + static func isVisible(groupCount: Int, showsEmptyState: Bool) -> Bool { + groupCount > 0 || showsEmptyState + } + var body: some View { - if !groups.isEmpty { + if Self.isVisible(groupCount: groups.count, showsEmptyState: showsEmptyState) { VStack(alignment: .leading, spacing: 0) { - Color.rowSeparator - .frame(height: 1) + // The empty state sits straight under the details card, with no rule (node 12527:81838). + if !groups.isEmpty { + Color.rowSeparator + .frame(height: 1) + } - Text("Favorite Public Groups") + Text(Self.title) .font(.appTextLarge) .foregroundStyle(Color.textMain) .padding(.top, 24) - .padding(.bottom, 24) + .padding(.bottom, groups.isEmpty ? 10 : 24) .accessibilityAddTraits(.isHeader) + if groups.isEmpty { + Text(Self.emptyText) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .accessibilityIdentifier("profile-featured-groups-empty") + } + VStack(spacing: 16) { ForEach(groups) { group in Button { onTap(group.id) } label: { @@ -41,6 +70,7 @@ struct FeaturedGroupsSection: View { } } } + .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, ProfileHeaderMetrics.inset) .accessibilityIdentifier("profile-featured-groups") } diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index 9e6f3abfa..384c0e923 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -141,10 +141,11 @@ struct ProfileActionCircle: View { } } -/// The header's edit button ("Edit Profile" unless titled otherwise), a flat filled capsule beside ``ProfileActionCircle``. +/// The header's edit button ("Edit Profile" unless titled otherwise), a flat filled capsule, blue when `isAccented`, beside ``ProfileActionCircle``. struct ProfileEditCapsule: View { var title: String = "Edit Profile" + var isAccented: Bool = false let action: () -> Void var body: some View { @@ -153,7 +154,7 @@ struct ProfileEditCapsule: View { .font(.appTextSmall) .padding(.horizontal, 18) } - .buttonStyle(ProfileActionButtonStyle()) + .buttonStyle(ProfileActionButtonStyle(isAccented: isAccented)) } } @@ -163,11 +164,14 @@ private struct ProfileActionButtonStyle: ButtonStyle { static var height: CGFloat { 38 } + /// Fills the capsule with the currency creator's `Next` blue and a white label instead. + var isAccented = false + func makeBody(configuration: Configuration) -> some View { configuration.label - .foregroundStyle(Color.textMain) + .foregroundStyle(isAccented ? Color.white : Color.textMain) .frame(height: Self.height) - .background(Capsule().fill(Color.action.opacity(0.1))) + .background(Capsule().fill(isAccented ? Color.blue : Color.action.opacity(0.1))) .contentShape(Capsule()) .opacity(configuration.isPressed ? 0.7 : 1.0) .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift b/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift index e407a8468..d4e6ca8d2 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift @@ -194,3 +194,12 @@ extension TipCardCustomization { .with { $0.hex = colorHex } } } + +extension Profile { + /// Whether the profile has a photo, a cover and a non-blank bio. + public var isComplete: Bool { + profilePicture != nil + && coverPicture != nil + && bio?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } +} diff --git a/FlipcashTests/ProfileCompletenessTests.swift b/FlipcashTests/ProfileCompletenessTests.swift new file mode 100644 index 000000000..6a46da449 --- /dev/null +++ b/FlipcashTests/ProfileCompletenessTests.swift @@ -0,0 +1,90 @@ +// +// ProfileCompletenessTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Profile completeness and featured-groups section") +struct ProfileCompletenessTests { + + private static func picture() -> ProfilePicture { + let blobID = BlobID(uuid: UUID()) + return ProfilePicture(blobID: blobID, thumbnailBlobID: blobID) + } + + private static func profile(photo: Bool, cover: Bool, bio: String?, username: Username? = nil) -> Profile { + Profile( + displayName: "Mike", + phone: Optional.none, + email: nil, + profilePicture: photo ? picture() : nil, + username: username, + bio: bio, + coverPicture: cover ? picture() : nil + ) + } + + @Test("Missing photo, cover or a non-blank bio leaves the profile incomplete", arguments: [ + (false, false, nil), + (true, true, ""), + (true, true, " "), + (true, false, "hi"), + (false, true, "hi"), + ] as [(Bool, Bool, String?)]) + func incomplete(photo: Bool, cover: Bool, bio: String?) { + #expect(!Self.profile(photo: photo, cover: cover, bio: bio).isComplete) + } + + @Test("Photo, cover and bio complete the profile without a username") + func complete() { + #expect(Self.profile(photo: true, cover: true, bio: "hi", username: nil).isComplete) + } + + @Test("Own profile shows the section with no groups; others hide it") + func visibility() { + #expect(FeaturedGroupsSection.isVisible(groupCount: 0, showsEmptyState: true)) + #expect(FeaturedGroupsSection.isVisible(groupCount: 2, showsEmptyState: true)) + #expect(!FeaturedGroupsSection.isVisible(groupCount: 0, showsEmptyState: false)) + #expect(FeaturedGroupsSection.title == "Favorite Community Chats") + #expect(FeaturedGroupsSection.emptyText == "None yet") + } + + @Test("Own profile says None yet only once favorites are known empty") + func ownEmptyState() { + #expect(FeaturedGroupsSection.ownProfileShowsEmptyState(hasUsername: false, isLoaded: false)) + #expect(!FeaturedGroupsSection.ownProfileShowsEmptyState(hasUsername: true, isLoaded: false)) + #expect(FeaturedGroupsSection.ownProfileShowsEmptyState(hasUsername: true, isLoaded: true)) + } + + @Test("The edit button is accented and asks to complete until the profile is complete") + func editButton() { + let incomplete = EditProfileButtonState(isProfileComplete: false) + #expect(incomplete.title == "Complete Your Profile") + #expect(incomplete.isAccented) + let complete = EditProfileButtonState(isProfileComplete: true) + #expect(complete.title == "Edit Profile") + #expect(!complete.isAccented) + } + + @Test("Favorites count as loaded only after a read or a save") + func favoritesLoaded() async { + let unloaded = FeaturedGroups(fetching: { _ in [] }) + #expect(!unloaded.isLoaded) + unloaded.replace(with: []) + #expect(unloaded.isLoaded) + + let loading = FeaturedGroups(fetching: { _ in [] }) + let read = await loading.load(username: Username("mike")!) + #expect(read && loading.isLoaded) + + struct Failure: Error {} + let failing = FeaturedGroups(fetching: { _ in throw Failure() }) + _ = await failing.load(username: Username("mike")!) + #expect(!failing.isLoaded) + } +} From b4238196764d12b09f5efbb42d6707050a6c1fb9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 12:37:17 -0400 Subject: [PATCH 4/4] feat(profile): show a plain Username row on Edit Profile Frame 13 (node 12527:81886) changes Edit Profile: - The cover chip reads "Add cover photo" and the avatar caption "Add profile photo" until each is set, then "Change cover" / "Change photo". - "Claim your username" is gone. The Username row shows the current @handle, auto-assigned or chosen, or "Add a username" when there is none. - The gate dialog reads "$100 Balance Required" with the body "Setting a custom username requires a balance of $100 or more" and the buttons "Add Money" and "Dismiss". The amount comes from the server flag; formattedDroppingZeroFraction() keeps "$100" whole and "$12.50" at two digits. --- .../Main/Profile/EditProfileScreen.swift | 59 ++++++++++++------- .../Main/Profile/ProfileFieldCard.swift | 5 +- .../Main/Username/DialogItem+Username.swift | 9 +-- .../Screens/Main/Username/UsernameGate.swift | 24 +++++++- FlipcashTests/EditProfileCopyTests.swift | 32 ++++++++++ FlipcashTests/UsernameDialogTests.swift | 15 +++-- FlipcashTests/UsernameGateTests.swift | 8 +++ 7 files changed, 112 insertions(+), 40 deletions(-) create mode 100644 FlipcashTests/EditProfileCopyTests.swift diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index a4f936b44..fd8c23b2c 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -65,7 +65,7 @@ struct EditProfileScreen: View { .buttonStyle(.plain) .accessibilityIdentifier("edit-profile-cover") .overlay(alignment: .topTrailing) { - Button("Change cover") { + Button(EditProfileCopy.coverChip(hasCover: profile?.coverPicture != nil)) { router.push(.changeCoverPicture) } .buttonStyle(.plain) @@ -91,7 +91,7 @@ struct EditProfileScreen: View { .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } .overlay(alignment: .bottomTrailing) { cameraBadge } - Text("Change photo") + Text(EditProfileCopy.photoCaption(hasPhoto: profile?.profilePicture != nil)) .font(.appTextSmall) .foregroundStyle(Color.textSecondary) .padding(.bottom, 6) @@ -137,26 +137,21 @@ struct EditProfileScreen: View { } .accessibilityIdentifier("edit-profile-minimum") - FieldCard(title: "Favorite Public Groups", value: featuredGroupsSummary, placeholder: "Add groups") { + FieldCard(title: "Favorite Community Chats", value: featuredGroupsSummary, placeholder: "Add chats") { router.push(.editFeaturedGroups) } .accessibilityIdentifier("edit-profile-featured-groups") } } - @ViewBuilder private var usernameCard: some View { - if needsUsernameClaim { - FieldCard(title: "Username", value: "Claim your username", placeholder: "", valueIsPrompt: true, action: beginUsernameClaim) - .accessibilityIdentifier("edit-profile-username") - } else if let username = profile?.username { - // No balance gate on a held handle: the gate exists to stop squatting at claim time, - // and a user who already holds one has cleared it. - FieldCard(title: "Username", value: "@\(username.value)", placeholder: "") { - router.push(.username(username)) - } - .accessibilityIdentifier("edit-profile-username") - } + FieldCard( + title: "Username", + value: EditProfileCopy.usernameValue(profile?.username), + placeholder: "Add a username", + action: openUsername + ) + .accessibilityIdentifier("edit-profile-username") } /// How many groups the profile features, or nil for none. @@ -176,18 +171,38 @@ struct EditProfileScreen: View { return string } - // MARK: - Username claim - + // MARK: - Username - - /// The same entry the You tab's progress card takes: the claim screen once the balance clears - /// the server's minimum, the balance dialog otherwise. - private func beginUsernameClaim() { - switch usernameGate(session: session, minimum: session.userFlags?.usernameMinBalance) { - case .proceed: + /// A handle the user chose is opened directly: the balance gate exists to stop squatting at + /// claim time, and a user who holds one has cleared it. Otherwise the gate decides between the + /// claim screen and the balance dialog. + private func openUsername() { + let gate = usernameGate(session: session, minimum: session.userFlags?.usernameMinBalance) + switch usernameRowDestination(needsClaim: needsUsernameClaim, gate: gate) { + case .username: router.push(.username(profile?.username)) - case .addMoney(let minimum, _, _): + case .balanceRequired(let minimum): dialog = .usernameMinimumBalance(minimum: ratesController.displayCurrency.amount(minimum)) { router.presentAddMoney(.general, source: .usernameShortfall) } } } } + +/// The Edit Profile strings that depend on what the profile already holds. +enum EditProfileCopy { + /// The cover chip's label: "Add cover photo" until there is a cover, then "Change cover". + static func coverChip(hasCover: Bool) -> String { + hasCover ? "Change cover" : "Add cover photo" + } + + /// The caption under the avatar: "Add profile photo" until there is a photo, then "Change photo". + static func photoCaption(hasPhoto: Bool) -> String { + hasPhoto ? "Change photo" : "Add profile photo" + } + + /// The Username row's value, or nil when there is no handle to show. + static func usernameValue(_ username: Username?) -> String? { + username?.handle + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/ProfileFieldCard.swift b/Flipcash/Core/Screens/Main/Profile/ProfileFieldCard.swift index a749276ce..203b6c111 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfileFieldCard.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfileFieldCard.swift @@ -13,7 +13,6 @@ struct FieldCard: View { let title: String let value: String? let placeholder: String - var valueIsPrompt = false var lineLimit = 1 let action: VoidAction @@ -26,7 +25,7 @@ struct FieldCard: View { .foregroundStyle(Color.textSecondary) Text(value ?? placeholder) .font(.appTextMedium) - .foregroundStyle(value == nil || valueIsPrompt ? Color.textSecondary : Color.textMain) + .foregroundStyle(value == nil ? Color.textSecondary : Color.textMain) .lineLimit(lineLimit) .multilineTextAlignment(.leading) } @@ -46,7 +45,7 @@ struct FieldCard: View { } } -/// The "Change cover" button over the cover photo: an interactive Liquid Glass capsule on iOS 26, and on iOS 18 an +/// The cover photo's "Add cover photo" / "Change cover" button: an interactive Liquid Glass capsule on iOS 26, and on iOS 18 an /// opaque chip, since the standard chip's row fill is translucent and vanishes on a photo. struct CoverChip: ViewModifier { func body(content: Content) -> some View { diff --git a/Flipcash/Core/Screens/Main/Username/DialogItem+Username.swift b/Flipcash/Core/Screens/Main/Username/DialogItem+Username.swift index 038566e64..c322f0eb7 100644 --- a/Flipcash/Core/Screens/Main/Username/DialogItem+Username.swift +++ b/Flipcash/Core/Screens/Main/Username/DialogItem+Username.swift @@ -25,12 +25,13 @@ extension DialogItem { /// than a constant — the number is a flag, and the copy has to track it. static func usernameMinimumBalance(minimum: FiatAmount, onAddMoney: @escaping () -> Void) -> DialogItem { // Whole amounts read as "$100", not "$100.00" — this is a threshold - // being quoted, not a balance being reported. - let amount = minimum.formatted(minimumFractionDigits: 0) + // being quoted, not a balance being reported. A fractional one keeps + // both digits ("$12.50"). + let amount = minimum.formattedDroppingZeroFraction() return .info( - title: "\(amount) Minimum Balance Required", - subtitle: "In order to stop username squatting getting a username requires a total Flipcash balance of at least \(amount) \(minimum.currency.rawValue.uppercased())" + title: "\(amount) Balance Required", + subtitle: "Setting a custom username requires a balance of \(amount) or more" ) { .standard("Add Money", action: onAddMoney); .dismiss(kind: .subtle) diff --git a/Flipcash/Core/Screens/Main/Username/UsernameGate.swift b/Flipcash/Core/Screens/Main/Username/UsernameGate.swift index 67acf52a9..6d342b0a6 100644 --- a/Flipcash/Core/Screens/Main/Username/UsernameGate.swift +++ b/Flipcash/Core/Screens/Main/Username/UsernameGate.swift @@ -81,9 +81,27 @@ func usernameGate(session: some UsernameBalanceReading, minimum: TokenAmount?) - return .proceed } -/// Whether to offer the custom-username card: true unless the user holds a -/// handle they chose themselves. A server-assigned handle still counts as -/// unclaimed. +/// Whether the handle is still unclaimed: true unless the user holds one they +/// chose themselves. A server-assigned handle still counts as unclaimed, and +/// an unclaimed handle is what the balance gate applies to. func usernameNeedsClaim(username: Username?, isAutoAssigned: Bool) -> Bool { username == nil || isAutoAssigned } + +/// Where tapping the Username row on Edit Profile goes. +enum UsernameRowDestination: Equatable { + /// The username screen. + case username + /// The balance dialog, naming `minimum`. + case balanceRequired(minimum: FiatAmount) +} + +/// A handle the user chose opens directly, since they cleared the gate when they +/// claimed it; otherwise `gate` decides between the username screen and the balance dialog. +func usernameRowDestination(needsClaim: Bool, gate: UsernameGate) -> UsernameRowDestination { + guard needsClaim else { return .username } + switch gate { + case .proceed: return .username + case .addMoney(let minimum, _, _): return .balanceRequired(minimum: minimum) + } +} diff --git a/FlipcashTests/EditProfileCopyTests.swift b/FlipcashTests/EditProfileCopyTests.swift new file mode 100644 index 000000000..f87a5af62 --- /dev/null +++ b/FlipcashTests/EditProfileCopyTests.swift @@ -0,0 +1,32 @@ +// +// EditProfileCopyTests.swift +// FlipcashTests +// + +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Edit Profile copy") +struct EditProfileCopyTests { + + @Test("Cover chip follows whether a cover is set") + func coverChip() { + #expect(EditProfileCopy.coverChip(hasCover: false) == "Add cover photo") + #expect(EditProfileCopy.coverChip(hasCover: true) == "Change cover") + } + + @Test("Photo caption follows whether a photo is set") + func photoCaption() { + #expect(EditProfileCopy.photoCaption(hasPhoto: false) == "Add profile photo") + #expect(EditProfileCopy.photoCaption(hasPhoto: true) == "Change photo") + } + + @Test("Username value shows any handle, nil when there is none") + func usernameValue() throws { + let assigned = try #require(Username("mike_l_2")) + #expect(EditProfileCopy.usernameValue(assigned) == "@mike_l_2") + #expect(EditProfileCopy.usernameValue(nil) == nil) + } +} diff --git a/FlipcashTests/UsernameDialogTests.swift b/FlipcashTests/UsernameDialogTests.swift index 901582a4b..b66eefc7a 100644 --- a/FlipcashTests/UsernameDialogTests.swift +++ b/FlipcashTests/UsernameDialogTests.swift @@ -19,8 +19,8 @@ struct UsernameDialogTests { func gateDialog_namesMinimum() { let item = DialogItem.usernameMinimumBalance(minimum: .usd(100), onAddMoney: {}) - #expect(item.title == "$100 Minimum Balance Required") - #expect(item.subtitle?.contains("$100 USD") == true) + #expect(item.title == "$100 Balance Required") + #expect(item.subtitle == "Setting a custom username requires a balance of $100 or more") } @Test("The gate drops trailing zeros rather than reading $100.00") @@ -98,7 +98,7 @@ struct UsernameDialogTests { func submissionDialog_insufficientBalance_isGateDialog() { let item = DialogItem.usernameSubmission(.insufficientBalance, minimum: .usd(100), onAddMoney: {}) - #expect(item.title == "$100 Minimum Balance Required") + #expect(item.title == "$100 Balance Required") #expect(item.actions[0].title == "Add Money") } @@ -126,12 +126,11 @@ struct UsernameDialogTests { #expect(item.subtitle == "Try again") } - @Test("The gate names the minimum's own currency rather than assuming USD") - func gateDialog_nonUSDMinimum_namesItsCurrency() { - let item = DialogItem.usernameMinimumBalance(minimum: FiatAmount(value: 100, currency: .cad), onAddMoney: {}) + @Test("A fractional minimum keeps its cents") + func gateDialog_fractionalMinimum() { + let item = DialogItem.usernameMinimumBalance(minimum: FiatAmount(value: Decimal(string: "12.50")!, currency: .usd), onAddMoney: {}) - #expect(item.subtitle?.contains("CAD") == true) - #expect(item.subtitle?.contains("USD") == false) + #expect(item.title == "$12.50 Balance Required") } @Test("Every rejection the user can type their way out of uses the grey banner") diff --git a/FlipcashTests/UsernameGateTests.swift b/FlipcashTests/UsernameGateTests.swift index 5ae149352..574ff4408 100644 --- a/FlipcashTests/UsernameGateTests.swift +++ b/FlipcashTests/UsernameGateTests.swift @@ -38,6 +38,14 @@ struct UsernameGateTests { TokenAmount(wholeTokens: usd, mint: .usdf) } + @Test("Tapping the Username row: a chosen handle skips the gate, an unclaimed one is gated") + func usernameRowDestinations() { + let short = UsernameGate.addMoney(minimum: .usd(100), shortfall: .usd(100), fraction: 0) + #expect(usernameRowDestination(needsClaim: false, gate: short) == .username) + #expect(usernameRowDestination(needsClaim: true, gate: .proceed) == .username) + #expect(usernameRowDestination(needsClaim: true, gate: short) == .balanceRequired(minimum: .usd(100))) + } + @Test("A balance below the minimum is sent to Add Money, carrying the shortfall") func gate_belowMinimum_addMoney() { let gate = usernameGate(session: StubBalances(usd: 87.5), minimum: minimum(usd: 100))