diff --git a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift index 650b245c9..9ff68d75f 100644 --- a/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift +++ b/Flipcash/Core/Navigation/AppRouter+NestedSheet.swift @@ -102,14 +102,11 @@ private struct BuySheetRoot: View { } /// Root view for the `.tips` sheet — owns the `NavigationStack` bound to -/// `router[.tips]` and the profile-creation state. -/// -/// The state lives here, not on a screen: the name entered on one push has to -/// survive the next, and an upload must outlive the screen that started it. +/// `router[.tips]`. struct TipsSheetRoot: View { @Environment(AppRouter.self) private var router - @State private var creationState = ProfileCreationState() + @Environment(ProfileCreationState.self) private var creationState var body: some View { @Bindable var router = router @@ -123,7 +120,6 @@ struct TipsSheetRoot: View { } } } - .environment(creationState) // A rejected blob is terminal and a reserved upload is signed against // one byte count, so dismissing mid-upload loses real work. .interactiveDismissDisabled(creationState.isUploading) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 8e06856d0..ef443e669 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -371,12 +371,10 @@ extension HomeTab { /// The Chat tab — the tip conversations surface. Mirrors `TipsSheetRoot` (the /// `.tips` sheet) as embedded tab chrome: the same `NavigationStack` bound to -/// `router[.tips]` and the same profile-creation state, minus the sheet's close -/// button. +/// `router[.tips]`, minus the sheet's close button. private struct ChatTab: View { @Environment(AppRouter.self) private var router - @State private var creationState = ProfileCreationState() var body: some View { @Bindable var router = router @@ -384,7 +382,6 @@ private struct ChatTab: View { TipsScreen(isEmbedded: true) .appRouterDestinations() } - .environment(creationState) } } diff --git a/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift b/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift index def99b9b8..a43a25877 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift @@ -10,9 +10,9 @@ private let logger = Logger(label: "flipcash.profile-creation") /// Drives profile creation across the name and photo screens. /// -/// Owned by the Tips sheet root so the name entered on one screen survives the -/// push to the next, and so a resumable upload outlives the screen that -/// started it. +/// Owned by the session container, so every stack that can push the name and +/// photo screens finds it, the name entered on one screen survives the push to +/// the next, and a resumable upload outlives the screen that started it. @Observable final class ProfileCreationState { diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 9a80b3a61..0ddcf47dc 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -532,6 +532,8 @@ final class SessionContainer { let profileAvatars: ProfileAvatarStore /// Identities for chat senders the chat's own roster leaves out — see ``KnownAuthorDirectory``. let knownAuthors: KnownAuthorDirectory + /// The in-progress profile setup, shared by every stack that can push its screens. + let profileCreationState = ProfileCreationState() /// Fills cash link cards in for chat transcripts. Container-scoped so a link shared into two /// conversations is looked up once, and holding a `GiftCardAccountReading` rather than the /// `Client` it was built from — rendering a card must never reach a path that claims it. @@ -789,6 +791,7 @@ final class SessionContainer { .environment(onrampDeeplinkInbox) .environment(conversationController) .environment(blocklistController) + .environment(profileCreationState) } } diff --git a/FlipcashTests/ProfileSetupEnvironmentTests.swift b/FlipcashTests/ProfileSetupEnvironmentTests.swift new file mode 100644 index 000000000..c1c3d7444 --- /dev/null +++ b/FlipcashTests/ProfileSetupEnvironmentTests.swift @@ -0,0 +1,47 @@ +// +// ProfileSetupEnvironmentTests.swift +// FlipcashTests +// + +import SwiftUI +import Testing +@testable import Flipcash + +/// The profile-setup screens read `ProfileCreationState` from the environment. +/// It once came only from the tips stack's roots, so pushing either screen onto +/// any other stack aborted on the missing object. +/// +/// Each test renders the destination with only the app and session environment, +/// which is all a non-tips stack provides. A regression aborts the test run +/// rather than failing one test: a missing environment object is a fatal error. +@MainActor +@Suite("Profile Setup Environment Tests") +struct ProfileSetupEnvironmentTests { + + @Test("The name step renders with only the session environment") + func nameStepRendersWithoutStackState() throws { + let window = try Self.host(.profileName) + #expect(window.rootViewController?.view.subviews.isEmpty == false) + } + + @Test("The photo step renders with only the session environment") + func photoStepRendersWithoutStackState() throws { + let window = try Self.host(.profilePhoto) + #expect(window.rootViewController?.view.subviews.isEmpty == false) + } + + private static func host(_ destination: AppRouter.Destination) throws -> UIWindow { + let sessionContainer = try SessionContainer.makeTest(holdings: []) + let view = NavigationStack { + DestinationView(destination: destination) + } + .injectingEnvironment(from: sessionContainer) + .injectingEnvironment(from: Container.mock) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + window.layoutIfNeeded() + return window + } +}