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/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/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..3b4f1bf6e 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 @@ -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/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/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/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/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/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/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/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/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/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/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/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/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/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/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) + } +} 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)) 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" + ) } } 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