diff --git a/Flipcash/Core/Screens/Onboarding/IntroScreen.swift b/Flipcash/Core/Screens/Onboarding/IntroScreen.swift index 659ad8999..22184859a 100644 --- a/Flipcash/Core/Screens/Onboarding/IntroScreen.swift +++ b/Flipcash/Core/Screens/Onboarding/IntroScreen.swift @@ -39,7 +39,30 @@ private struct IntroScreenContent: View { // MARK: - Body - + /// An account is waiting on its resumed step, so the intro would only flash before it. + /// Rendering this instead keeps the stack uncreated until the path is seeded, which lets + /// the first resumed screen appear without a push animation. + private var isAwaitingResume: Bool { + sessionAuthenticator.pendingOnboarding != nil && viewModel.path.isEmpty + } + var body: some View { + if isAwaitingResume { + Background(color: .backgroundMain) { + LoadingView(color: .white) + } + .task(id: sessionAuthenticator.pendingOnboarding?.keyAccount.ownerPublicKey.base58, resumeIfPending) + } else { + introStack + } + } + + private func resumeIfPending() async { + guard let account = sessionAuthenticator.pendingOnboarding else { return } + await viewModel.resumeOnboarding(with: account) + } + + private var introStack: some View { NavigationStack(path: $viewModel.path) { Background(color: .backgroundMain) { VStack { @@ -123,21 +146,24 @@ private struct IntroScreenContent: View { LoginScreen() case .accessKey: AccessKeyScreen(viewModel: viewModel) - .navigationBarBackButtonHidden(true) + .navigationBarBackButtonHidden(viewModel.hidesBackButton(for: .accessKey)) case .accessKeyHelp: AccessKeyHelpScreen() case .pushNotifications: NotificationPermissionScreen(viewModel: viewModel) + .navigationBarBackButtonHidden(viewModel.hidesBackButton(for: .pushNotifications)) case .pushNotificationsDenied: NotificationPermissionDeniedScreen(viewModel: viewModel) + .navigationBarBackButtonHidden(viewModel.hidesBackButton(for: .pushNotificationsDenied)) case .displayName: if let nameVM = viewModel.nameViewModel { OnboardingNameScreen(viewModel: nameVM) - .navigationBarBackButtonHidden(true) + .navigationBarBackButtonHidden(viewModel.hidesBackButton(for: .displayName)) } } } } + .task(id: sessionAuthenticator.pendingOnboarding?.keyAccount.ownerPublicKey.base58, resumeIfPending) } } diff --git a/Flipcash/Core/Screens/Onboarding/LoginScreen.swift b/Flipcash/Core/Screens/Onboarding/LoginScreen.swift index 4420ce719..ba2abc271 100644 --- a/Flipcash/Core/Screens/Onboarding/LoginScreen.swift +++ b/Flipcash/Core/Screens/Onboarding/LoginScreen.swift @@ -137,7 +137,7 @@ struct LoginScreen: View { buttonState = .success try await Task.delay(milliseconds: 500) - sessionAuthenticator.completeLogin(with: initializedAccount) + sessionAuthenticator.beginOnboarding(with: initializedAccount) } catch { try await Task.delay(milliseconds: 500) buttonState = .normal diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index 762e20a2c..fc21cddf4 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -8,6 +8,7 @@ import SwiftUI import FlipcashUI import FlipcashCore +import UserNotifications @Observable class OnboardingViewModel { @@ -24,15 +25,46 @@ class OnboardingViewModel { @ObservationIgnored private let sessionAuthenticator: SessionAuthenticator @ObservationIgnored private var initializedAccount: InitializedAccount? + /// True once ``resumeOnboarding(with:)`` has taken over: the flow was entered from a launch + /// or an interactive login rather than from "Create a New Account". + private(set) var isResumedFlow = false + + /// True from ``resumeOnboarding(with:)`` until its first step replaces the path, so that + /// step becomes the stack's first screen instead of stacking over the intro or login. + @ObservationIgnored private var awaitingResumedRoot = false + @ObservationIgnored private var resumedOwner: PublicKey? + @ObservationIgnored private let pushStatus: () async -> UNAuthorizationStatus + @ObservationIgnored private let hasDisplayName: (InitializedAccount) async -> Bool + @ObservationIgnored private let finishLogin: (InitializedAccount) -> Void + /// Built on first call to ``navigateToDisplayName``; the display-name step /// runs pre-login, so it owns its submission against the in-flight owner. var nameViewModel: OnboardingNameViewModel? // MARK: - Init - - init(container: Container) { + /// The closures are seams for tests; the defaults read the real notification status and + /// profile, and open the session. + /// + /// - Parameters: + /// - pushStatus: the current notification authorization. + /// - hasDisplayName: whether the account's profile already has a display name. + /// - finishLogin: opens the session once onboarding is done. + init( + container: Container, + pushStatus: @escaping () async -> UNAuthorizationStatus = { await PushController.fetchStatus() }, + hasDisplayName: ((InitializedAccount) async -> Bool)? = nil, + finishLogin: ((InitializedAccount) -> Void)? = nil + ) { self.container = container self.sessionAuthenticator = container.sessionAuthenticator + self.pushStatus = pushStatus + self.hasDisplayName = hasDisplayName ?? { [flipClient = container.flipClient, accountManager = container.accountManager] account in + await Self.lookUpDisplayName(for: account, flipClient: flipClient, accountManager: accountManager) + } + self.finishLogin = finishLogin ?? { [authenticator = container.sessionAuthenticator] account in + authenticator.completeLogin(with: account) + } } // MARK: - Action - @@ -133,7 +165,7 @@ class OnboardingViewModel { /// undetermined, otherwise finishes login. Contacts access is requested /// later, in-app. private func advanceFromNameStep() async { - let pushStatus = await PushController.fetchStatus() + let pushStatus = await pushStatus() switch pushStatus { case .notDetermined: navigateToPushNotifications() @@ -160,7 +192,7 @@ class OnboardingViewModel { ) try await Task.delay(milliseconds: 500) - sessionAuthenticator.completeLogin(with: initializedAccount) + sessionAuthenticator.beginOnboarding(with: initializedAccount) } catch { // Login failed silently } @@ -238,11 +270,60 @@ class OnboardingViewModel { return } - sessionAuthenticator.completeLogin(with: initializedAccount) + OnboardingProgress.setCompleted(true, owner: initializedAccount.keyAccount.ownerPublicKey) + finishLogin(initializedAccount) Analytics.onboardingCompleted() } + // MARK: - Resume - + + /// Runs the onboarding steps still owed by an account that is already signed in: a launch + /// after a kill mid-onboarding, or an interactive login (seed entry, account picker, deep + /// link). Asks for a display name only when the profile has none, then applies the same + /// push rule as a new account, so already-decided permissions finish straight away. + func resumeOnboarding(with account: InitializedAccount) async { + // The task that calls this restarts when the intro swaps for the stack; one run is enough. + guard resumedOwner != account.keyAccount.ownerPublicKey else { return } + resumedOwner = account.keyAccount.ownerPublicKey + initializedAccount = account + nameViewModel = nil + isResumedFlow = true + awaitingResumedRoot = true + + guard !(await hasDisplayName(account)) else { + await advanceFromNameStep() + return + } + + navigateToDisplayName(owner: account.keyAccount.owner) + } + + /// Longest the resume screen waits on the profile before treating it as unreadable. + private static let profileLookupTimeout: Double = 5 + + private static func lookUpDisplayName( + for account: InitializedAccount, + flipClient: FlipClient, + accountManager: AccountManager + ) async -> Bool { + do { + let profile = try await withTimeout(seconds: profileLookupTimeout) { + try await flipClient.fetchProfile(userID: account.userID, owner: account.keyAccount.owner) + } + accountManager.cacheProfile(profile, ownerPublicKey: account.keyAccount.ownerPublicKey) + return profile.displayName?.isEmpty == false + } catch { + // Unreadable profile: trust a name cached on the account's keychain entry, and + // otherwise treat the name as missing. Matches Android (a null profile counts as + // no display name), and every account needs a name for tips and chat. + let cached = accountManager.fetchHistorical() + .first { $0.account.ownerPublicKey == account.keyAccount.ownerPublicKey }? + .displayName + return cached?.isEmpty == false + } + } + // MARK: - Navigation - func navigateToRoot() { @@ -261,18 +342,39 @@ class OnboardingViewModel { path = [.accessKey] } + /// Whether `step` hides its back button, which also turns off swipe-back. + /// + /// A new account hides it on the access key and name steps only. A resumed flow also hides + /// it on its first screen so it can't back out to the intro or login; a later push step may + /// return to the name step only when the name step is in this flow's path. + func hidesBackButton(for step: OnboardingPath) -> Bool { + guard isResumedFlow else { + return step == .accessKey || step == .displayName + } + return step == .displayName || path.first == step + } + + private func place(_ step: OnboardingPath) { + if awaitingResumedRoot { + awaitingResumedRoot = false + path = [step] + } else { + path.append(step) + } + } + func navigateToPushNotifications() { - path.append(.pushNotifications) + place(.pushNotifications) } func navigateToPushNotificationsDenied() { path.append(.pushNotificationsDenied) } - func navigateToDisplayName() { + func navigateToDisplayName(owner: KeyPair? = nil) { if nameViewModel == nil { let vm = OnboardingNameViewModel( - owner: inflightMnemonic.solanaKeyPair(), + owner: owner ?? inflightMnemonic.solanaKeyPair(), flipClient: container.flipClient, ) vm.onComplete = { [weak self] in @@ -280,7 +382,7 @@ class OnboardingViewModel { } nameViewModel = vm } - path.append(.displayName) + place(.displayName) } } @@ -296,3 +398,41 @@ nonisolated enum OnboardingPath { case pushNotifications case pushNotificationsDenied } + +private struct LookupTimedOut: Error {} + +/// Runs `operation`, throwing ``LookupTimedOut`` if it hasn't finished within `seconds`. +/// +/// Races two unstructured tasks rather than a task group: a group waits for every child, and a +/// gRPC call that ignores cancellation would hold the caller past the deadline. +private func withTimeout(seconds: Double, _ operation: @escaping @Sendable () async throws -> T) async throws -> T { + let gate = OnceGate() + return try await withCheckedThrowingContinuation { continuation in + Task { + do { + let value = try await operation() + if gate.claim() { continuation.resume(returning: value) } + } catch { + if gate.claim() { continuation.resume(throwing: error) } + } + } + Task { + try? await Task.sleep(for: .seconds(seconds)) + if gate.claim() { continuation.resume(throwing: LookupTimedOut()) } + } + } +} + +private final class OnceGate: @unchecked Sendable { + private let lock = NSLock() + private var claimed = false + + /// True for the first caller only. + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + if claimed { return false } + claimed = true + return true + } +} diff --git a/Flipcash/Core/Session/OnboardingProgress.swift b/Flipcash/Core/Session/OnboardingProgress.swift new file mode 100644 index 000000000..b49ce53da --- /dev/null +++ b/Flipcash/Core/Session/OnboardingProgress.swift @@ -0,0 +1,46 @@ +// +// OnboardingProgress.swift +// Flipcash +// + +import Foundation +import FlipcashCore + +/// Per-account record of whether onboarding finished, so a launch after a kill mid-flow can +/// pick up where the user left off. +/// +/// An account with no entry counts as completed: builds that predate the marker never wrote +/// one, and those users must not be sent back through onboarding. +enum OnboardingProgress { + + enum LaunchRoute: Equatable { + case home + case resumeOnboarding + } + + @Defaults(.onboardingCompleted) + private static var markers: [String: Bool]? + + /// False only when the account was registered or signed in and never finished onboarding. + static func isCompleted(owner: PublicKey) -> Bool { + markers?[owner.base58] ?? true + } + + static func setCompleted(_ completed: Bool, owner: PublicKey) { + var updated = markers ?? [:] + updated[owner.base58] = completed + markers = updated + } + + /// Where a launch with `owner` as the current account should land. + /// + /// The keychain outlives an uninstall but UserDefaults does not, so a first launch that + /// already finds a signed-in account is a reinstall: the permission decisions are gone and + /// the marker with them. That case reopens the marker so the push step is offered again. + static func launchRoute(for owner: PublicKey, isFreshInstall: Bool = false) -> LaunchRoute { + if isFreshInstall { + setCompleted(false, owner: owner) + } + return isCompleted(owner: owner) ? .home : .resumeOnboarding + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index a407440b2..2f0bb2686 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -30,6 +30,10 @@ final class SessionAuthenticator { /// Whether the user has been unlocked (biometrics/passcode verified). private(set) var isUnlocked: Bool = false + /// An account that is signed in but has not finished onboarding. The logged-out + /// screen picks it up and runs the remaining steps before calling ``completeLogin``. + private(set) var pendingOnboarding: InitializedAccount? + /// The top-level authentication state driving the view hierarchy. private(set) var state: AuthenticationState = .migrating @@ -85,7 +89,8 @@ final class SessionAuthenticator { // Update launch state. // This is a fresh install. - if UserDefaults.launchCount == nil { + let isFreshInstall = UserDefaults.launchCount == nil + if isFreshInstall { logger.debug("First launch...") } @@ -109,12 +114,7 @@ final class SessionAuthenticator { SharedKeychainMigration.migrateOwnerKeyIfNeeded() initializeState { userAccount in - let initializedAccount = InitializedAccount( - keyAccount: userAccount.keyAccount, - userID: userAccount.userID - ) - - self.completeLogin(with: initializedAccount) + self.resolveLaunch(of: userAccount, isFreshInstall: isFreshInstall) } didFindRecentAccount: { [weak self] description in guard let self else { return } @@ -143,7 +143,7 @@ final class SessionAuthenticator { // launch backfills the ID and this branch stops being reached. Task { do { - let account = try await self.initialize(using: description.account.mnemonic, isRegistration: false) + let account = try await self.initialize(using: description.account.mnemonic, isRegistration: false, interactive: false) self.completeLogin(with: account) Analytics.track(event: Analytics.GeneralEvent.autoLoginComplete) } catch { @@ -153,6 +153,31 @@ final class SessionAuthenticator { } } + /// Launch with a current account: goes home, or holds the account in ``pendingOnboarding`` + /// when the app was killed before its onboarding finished. + private func resolveLaunch(of userAccount: UserAccount, isFreshInstall: Bool) { + let account = InitializedAccount( + keyAccount: userAccount.keyAccount, + userID: userAccount.userID + ) + + switch OnboardingProgress.launchRoute(for: userAccount.keyAccount.ownerPublicKey, isFreshInstall: isFreshInstall) { + case .home: + completeLogin(with: account) + case .resumeOnboarding: + logger.info("Resuming onboarding", metadata: ["owner": "\(account.keyAccount.ownerPublicKey)"]) + pendingOnboarding = account + state = .loggedOut + } + } + + /// Hands a freshly authenticated account to the onboarding flow instead of opening the + /// session, so permissions and the display name are checked before home. + func beginOnboarding(with initializedAccount: InitializedAccount) { + pendingOnboarding = initializedAccount + state = .loggedOut + } + private func initializeState(count: Int = 0, didAuthenticate: @escaping (UserAccount) -> Void, didFindRecentAccount: @escaping (AccountDescription) -> Void) { logger.info("initializeState called", metadata: ["count": "\(count)"]) @@ -368,14 +393,21 @@ final class SessionAuthenticator { // MARK: - Login - - func initialize(using mnemonic: MnemonicPhrase, isRegistration: Bool) async throws -> InitializedAccount { + /// Registers or logs in. `interactive` is true when the user just asked for this login + /// (seed entry, account picker, deep link) and false for a silent relaunch; interactive + /// logins and registrations reopen the account's onboarding marker. + func initialize(using mnemonic: MnemonicPhrase, isRegistration: Bool, interactive: Bool = true) async throws -> InitializedAccount { loginButtonState = .loading defer { loginButtonState = .normal } do { - return try await authenticate(using: mnemonic, isRegistration: isRegistration) + return try await authenticate( + using: mnemonic, + isRegistration: isRegistration, + marksOnboardingIncomplete: isRegistration || interactive + ) } catch { ErrorReporting.captureError( error, @@ -388,7 +420,11 @@ final class SessionAuthenticator { /// Registers or logs the owner in and makes sure its accounts exist, without touching /// ``loginButtonState`` or reporting the failure. ``initialize`` adds both for a login /// the user is watching; ``refreshRecentAccount`` wants neither. - private func authenticate(using mnemonic: MnemonicPhrase, isRegistration: Bool) async throws -> InitializedAccount { + /// + /// - Parameter marksOnboardingIncomplete: writes the account's onboarding marker as `false` + /// before the account becomes current, so a kill between the two relaunches into onboarding + /// instead of home. Written after instead, that window skips onboarding for good. + private func authenticate(using mnemonic: MnemonicPhrase, isRegistration: Bool, marksOnboardingIncomplete: Bool) async throws -> InitializedAccount { let derivedKey: DerivedKey = .derive( using: .primary(), mnemonic: mnemonic @@ -424,6 +460,10 @@ final class SessionAuthenticator { derivationIndex: 0 ) + if marksOnboardingIncomplete { + OnboardingProgress.setCompleted(false, owner: keyAccount.ownerPublicKey) + } + accountManager.set( keyAccount: keyAccount, userID: userID @@ -446,7 +486,7 @@ final class SessionAuthenticator { /// request; here that case is a warning and the next launch tries again. private func refreshRecentAccount(_ keyAccount: KeyAccount) async { do { - _ = try await authenticate(using: keyAccount.mnemonic, isRegistration: false) + _ = try await authenticate(using: keyAccount.mnemonic, isRegistration: false, marksOnboardingIncomplete: false) } catch ErrorLoginAccount.denied { logger.error( "Login denied for a stored account, logging out", @@ -470,6 +510,7 @@ final class SessionAuthenticator { initializedAccount: initializedAccount ) + pendingOnboarding = nil state = .loggedIn(session) session.quickActionsController.configure() UserDefaults.wasLoggedIn = true @@ -497,7 +538,7 @@ final class SessionAuthenticator { logout() try await Task.delay(seconds: 1) - completeLogin( + beginOnboarding( with: try await initialize( using: mnemonic, isRegistration: false @@ -539,6 +580,7 @@ final class SessionAuthenticator { // another account's Coinbase orders. CoinbaseOrderEmail.unverifiedEmail = nil + pendingOnboarding = nil state = .loggedOut requiresForceLogout = false UserDefaults.wasLoggedIn = false @@ -1048,7 +1090,7 @@ struct InitializedAccount { let owner: AccountCluster let userID: UserID - fileprivate init(keyAccount: KeyAccount, userID: UserID) { + init(keyAccount: KeyAccount, userID: UserID) { self.keyAccount = keyAccount self.owner = .init( authority: .derive(using: .primary(), mnemonic: keyAccount.mnemonic), diff --git a/Flipcash/Keychain/Defaults.swift b/Flipcash/Keychain/Defaults.swift index e082082f6..e6a0759a7 100644 --- a/Flipcash/Keychain/Defaults.swift +++ b/Flipcash/Keychain/Defaults.swift @@ -10,6 +10,7 @@ import Foundation enum DefaultsKey: String { case wasLoggedIn = "com.flipcash.state.wasLoggedIn" case launchCount = "com.flipcash.state.launchCount" + case onboardingCompleted = "com.flipcash.state.onboardingCompleted" case cameraAutoStartDisabled = "com.flipcash.camera.autoStartDisabled" diff --git a/FlipcashTests/OnboardingResumeTests.swift b/FlipcashTests/OnboardingResumeTests.swift new file mode 100644 index 000000000..cb51cef44 --- /dev/null +++ b/FlipcashTests/OnboardingResumeTests.swift @@ -0,0 +1,194 @@ +// +// OnboardingResumeTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import UserNotifications +import FlipcashCore +@testable import Flipcash + +@Suite("Onboarding resume", .serialized) +@MainActor +struct OnboardingResumeTests { + + private static let userID = UUID(uuidString: "00000000-0000-0000-0000-0000000000BB")! + private static let owner = KeyAccount.mock.ownerPublicKey + + private final class Finished { + var accounts: [InitializedAccount] = [] + } + + private func makeViewModel( + push: UNAuthorizationStatus, + named: Bool, + finished: Finished + ) -> OnboardingViewModel { + OnboardingViewModel( + container: Container(), + pushStatus: { push }, + hasDisplayName: { _ in named }, + finishLogin: { finished.accounts.append($0) } + ) + } + + private func account() -> InitializedAccount { + InitializedAccount(keyAccount: .mock, userID: Self.userID) + } + + // MARK: - Launch - + + @Test("An account whose marker is false relaunches into onboarding, not home") + func markerFalse_resumesOnboarding() { + OnboardingProgress.setCompleted(false, owner: Self.owner) + defer { OnboardingProgress.setCompleted(true, owner: Self.owner) } + + #expect(OnboardingProgress.launchRoute(for: Self.owner) == .resumeOnboarding) + } + + /// Builds that predate the marker never wrote one; sending those users back through + /// onboarding on upgrade is the regression this guards. + @Test("An account with no marker goes home, so upgraded users skip onboarding") + func markerAbsent_goesHome() { + let stranger = MnemonicPhrase.generate(.words12).solanaKeyPair().publicKey + + #expect(OnboardingProgress.isCompleted(owner: stranger)) + #expect(OnboardingProgress.launchRoute(for: stranger) == .home) + } + + /// The keychain keeps the signed-in account across an uninstall while UserDefaults does + /// not, so the reinstall looks like an upgrade (no marker) unless the first launch says otherwise. + @Test("A first launch that finds a signed-in account is a reinstall and resumes onboarding") + func freshInstall_withAccount_resumesOnboarding() { + let owner = MnemonicPhrase.generate(.words12).solanaKeyPair().publicKey + defer { OnboardingProgress.setCompleted(true, owner: owner) } + + #expect(OnboardingProgress.launchRoute(for: owner, isFreshInstall: true) == .resumeOnboarding) + #expect(OnboardingProgress.launchRoute(for: owner) == .resumeOnboarding) + } + + @Test("Resuming with no display name starts at the name step and does not log in") + func resume_withoutName_startsAtName() async { + let finished = Finished() + let viewModel = makeViewModel(push: .notDetermined, named: false, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.displayName]) + #expect(finished.accounts.isEmpty) + } + + @Test("Resuming with a name and undecided push starts at the push step and does not log in") + func resume_withName_startsAtPush() async { + let finished = Finished() + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + #expect(finished.accounts.isEmpty) + } + + /// An unreadable profile is reported as "no name", the same as Android's null-profile check. + /// Resume and interactive login share this gate, so one case covers both. + @Test("An unreadable profile holds the user on the name step") + func resume_unreadableProfile_holdsOnName() async { + let finished = Finished() + let viewModel = makeViewModel(push: .authorized, named: false, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.displayName]) + #expect(finished.accounts.isEmpty) + } + + // MARK: - Interactive login - + + @Test("An interactive login with push already decided goes straight home and marks onboarding done") + func interactive_pushAuthorized_goesHome() async { + OnboardingProgress.setCompleted(false, owner: Self.owner) + defer { OnboardingProgress.setCompleted(true, owner: Self.owner) } + let finished = Finished() + let viewModel = makeViewModel(push: .authorized, named: true, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(finished.accounts.count == 1) + #expect(viewModel.path.isEmpty) + #expect(OnboardingProgress.isCompleted(owner: Self.owner)) + } + + @Test("An interactive login with undecided push shows the push step and stays in onboarding") + func interactive_pushUndetermined_showsPush() async { + OnboardingProgress.setCompleted(false, owner: Self.owner) + defer { OnboardingProgress.setCompleted(true, owner: Self.owner) } + let finished = Finished() + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + #expect(finished.accounts.isEmpty) + #expect(!OnboardingProgress.isCompleted(owner: Self.owner)) + } + + @Test("Beginning onboarding holds the account without opening the session") + func beginOnboarding_holdsAccount() { + let authenticator = SessionAuthenticator(container: Container()) + + authenticator.beginOnboarding(with: account()) + + #expect(authenticator.pendingOnboarding?.keyAccount.ownerPublicKey == Self.owner) + #expect(!authenticator.isLoggedIn) + } + + // MARK: - Back navigation - + + @Test("A resumed flow's first screen hides its back button, so it can't back out to the intro") + func resumed_rootHidesBack() async { + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: Finished()) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + #expect(viewModel.hidesBackButton(for: .pushNotifications)) + } + + @Test("In a resumed flow the push step can go back to the name step, and denied back to push") + func resumed_pushAfterName_canGoBack() async { + let viewModel = makeViewModel(push: .notDetermined, named: false, finished: Finished()) + + await viewModel.resumeOnboarding(with: account()) + #expect(viewModel.hidesBackButton(for: .displayName)) + + viewModel.navigateToPushNotifications() + viewModel.navigateToPushNotificationsDenied() + + #expect(viewModel.path == [.displayName, .pushNotifications, .pushNotificationsDenied]) + #expect(!viewModel.hidesBackButton(for: .pushNotifications)) + #expect(!viewModel.hidesBackButton(for: .pushNotificationsDenied)) + } + + @Test("A new account keeps today's back behavior: only the access key and name steps hide it") + func freshFlow_backUnchanged() { + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: Finished()) + + #expect(viewModel.hidesBackButton(for: .accessKey)) + #expect(viewModel.hidesBackButton(for: .displayName)) + #expect(!viewModel.hidesBackButton(for: .pushNotifications)) + #expect(!viewModel.hidesBackButton(for: .pushNotificationsDenied)) + } + + @Test("A resume that starts from a login screen replaces it, so back can't return to the login") + func resume_replacesLoginPath() async { + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: Finished()) + viewModel.path = [.login] + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + } +} diff --git a/FlipcashTests/Regressions/Regression_6ae1d311c6c10d79fa57232c.swift b/FlipcashTests/Regressions/Regression_6ae1d311c6c10d79fa57232c.swift index 364f8b702..1a0bde1a6 100644 --- a/FlipcashTests/Regressions/Regression_6ae1d311c6c10d79fa57232c.swift +++ b/FlipcashTests/Regressions/Regression_6ae1d311c6c10d79fa57232c.swift @@ -251,8 +251,10 @@ struct Regression_6ae1d311c6c10d79fa57232c { var rng = SplitMix(seed: seed) let source = Source() let cards = (10..<22).map { Self.groupCard(UInt8($0)) } - var rows: [(id: String, text: String, card: LinkCard?)] = (0..<60).map { i in - (id: "r\(i)", text: "row \(i)", card: i % 5 == 0 ? cards[(i / 5) % cards.count] : nil) + var rows: [(id: String, text: String, card: LinkCard?)] = [] + for i in 0..<60 { + let card: LinkCard? = i % 5 == 0 ? cards[(i / 5) % cards.count] : nil + rows.append((id: "r\(i)", text: "row \(i)", card: card)) } func items() -> [ChatItem] { rows.map { carded($0.id, $0.text, card: $0.card) } }