Views and Components

ContentView

ContentView is the first decision in the app: once the splash screen finishes, this is the view that decides whether a brand-new user sees the onboarding sheet or whether a returning user drops straight into the live, navigable app. It is intentionally tiny — a single if — because everything downstream (auth, tabs, deep links, paywalls) lives in LandingView. That separation is the point: the gate stays trivial, and the routing stays in one well-tested place.

It's a gate, not a hub

A lot of boilerplates cram onboarding, auth, and tab navigation into one giant ContentView. ShipThatApp deliberately splits them: ContentView only answers "onboarded or not?", then hands off to LandingView, which answers "signed in or not?" and owns the AppRouter navigation. Two small views beat one untestable monolith.

Where it sits in the launch flow

ShipThatAppApp shows the splash screen first, and only mounts ContentView (wrapped in RootView, the toast host) once SplashScreenStateManager reports .finished:

var body: some Scene {
    WindowGroup {
        if splashScreenState.state != .finished {
            SplashScreenView()
                .task { await dismissSplashScreenAfterDelay() }
        } else {
            RootView {
                ContentView()
            }
        }
    }
    .environment(splashScreenState)
    .environment(quickActionService)
    .environment(settingsManager)
}

So by the time ContentView appears, the app-wide managers are already in the environment. ContentView's only job is the onboarding fork.

The whole view

This is the real source — there is no hidden logic:

struct ContentView: View {
    @Environment(SplashScreenStateManager.self) private var splashScreenState
    @Environment(SettingsManager.self) private var settingsManager
    @State private var authManager = AuthManager.shared
    @State private var signInViewModel = SignInViewModel()
    @State private var purchaseManager = PurchaseManager()

    var body: some View {
        ZStack {
            if settingsManager.onboardingComplete {
                LandingView()
                    .environment(authManager)
                    .environment(signInViewModel)
                    .environment(purchaseManager)
                    .task {
                        await purchaseManager.updatePurchasedProducts()
                    }
            } else {
                OnboardingView()
            }
        }
    }
}

What each piece does

  • settingsManager.onboardingComplete is the fork. It is a stored, UserDefaults-backed property on the @Observable SettingsManager (see Settings), so when OnboardingView flips it to true, SwiftUI re-evaluates body and swaps in the app automatically — no manual notification, no navigation call.
  • ContentView owns the long-lived view models. AuthManager.shared, SignInViewModel, and PurchaseManager are created here as @State and injected into LandingView via .environment(...). Creating them at this level means they survive every navigation inside the authed app and are shared by every screen that needs them.
  • .task { await purchaseManager.updatePurchasedProducts() } refreshes RevenueCat entitlements the moment the authed shell appears, so paywalls and "Pro" gating reflect reality on launch. See Purchase Manager.
  • ZStack wraps the branches so you can layer transitions across the onboarding → app handoff.

@Observable, not @EnvironmentObject

ShipThatApp targets Swift 6 / iOS 17+ and uses the Observation framework throughout. State is injected with @Environment(SomeType.self) and .environment(value)not the legacy @EnvironmentObject / ObservableObject. If you're extending this view, follow the same pattern so the dependency graph stays consistent.

How to use it

You normally never instantiate ContentView yourself — ShipThatAppApp does. You interact with it indirectly:

  • To force-show onboarding (e.g. while building it), set SettingsManager.shared.onboardingComplete = false or call SettingsManager.shared.resetAllSettings(). The next body evaluation drops you back into OnboardingView.
  • To test the authed shell in isolation, the #Preview shows the minimum environment it needs:
#Preview {
    ContentView()
        .environment(SplashScreenStateManager())
        .environment(SettingsManager.shared)
}

Customize / extend it

  • Add a third gate. If you need a "force update" or "maintenance" screen before everything else, add a branch above the onboarding check — keep it boolean-driven off a manager so SwiftUI does the re-rendering for you.
  • Animate the handoff. Because both branches live in a ZStack, you can attach a .transition(...) and wrap the toggle in withAnimation (inside OnboardingView's completion) for a polished onboarding → app reveal.
  • Inject more shared dependencies. If a new feature needs an app-lifetime model, create it as @State here and pass it down with .environment(...), exactly like authManager and purchaseManager. Don't reach for new singletons unless the model is genuinely global.
  • LandingView (main app flow) — the auth fork, the TabView, and the AppRouter that ContentView hands off to.
  • Onboarding — the screen shown when onboardingComplete is false, and how it flips the flag.
  • SettingsSettingsManager, the source of truth for onboardingComplete.
  • Authentication — what happens after onboarding, inside LandingView.
Previous
Troubleshooting