Views and Components
LandingView (Main App Flow)
LandingView is the navigational heart of ShipThatApp. Once a user is past onboarding, ContentView hands off to LandingView, and from here on it owns everything: the signed-in vs. signed-out fork, the three-tab TabView, every push and sheet (via AppRouter), magic-link deep links, quick actions, and App Store review prompts. It is the one place where the app's structure is wired together.
There is no HomeView.swift
Older docs referred to a HomeView. The real authenticated root is LandingView.swift (ShipThatApp/Views/LandingView.swift). Its tab roots are WelcomeScreen, ContentScreenView, and SettingsView. If you grep for HomeView, you won't find it — use LandingView.
Why it's built this way
Most boilerplates scatter NavigationLinks through every screen and let each view present its own sheets. That gets unmaintainable fast: you can't deep-link, you can't programmatically jump to a tab, and you can't test navigation. ShipThatApp instead centralizes navigation in a single typed router — AppRouter — so every destination and sheet is an enum case, navigation is one method call from anywhere, and a shipthatapp:// URL maps to a screen for free.
The auth fork
LandingView shows one of two trees depending on whether AuthManager has a live session. The router is created once and shared down the tree via .environment:
@State private var router = AppRouter(initialTab: .welcome)
var body: some View {
Group {
if authManager.currentSession != nil {
authenticatedView
} else {
unauthenticatedView
}
}
.environment(router)
.sheet(item: $router.presentedSheet) { sheet in
sheetView(for: sheet)
}
.onOpenURL { incomingURL in
handleIncomingURL(incomingURL)
}
.onAppear { refreshCurrentSession() }
.onChange(of: scenePhase) { _, newValue in
if newValue == .active { checkQuickActions() }
}
}
A few things to note:
- Auth gate. When
authManager.currentSessionisnil, the unauthenticated stack showsSignInView. When a session exists, the tab bar appears. BecausecurrentSessionlives on an@Observablemanager, signing in or out re-renders this fork automatically. - One sheet host. All modals are presented from a single
.sheet(item: $router.presentedSheet), so any screen can present the paywall, feedback form, or profile editor by callingrouter.presentSheet(...). - Deep links + quick actions.
.onOpenURLroutes incoming URLs, and.onChange(of: scenePhase)fires pending Home-screen quick actions when the app becomes active.
The signed-out branch
When there's no session, the user gets a simple stack rooted at SignInView:
private var unauthenticatedView: some View {
NavigationStack(path: $router[.welcome]) {
SignInView()
.navigationDestination(for: AppDestination.self) { destination in
destinationView(for: destination)
}
}
}
SignInView is the Apple-first sign-in screen; tapping "View other sign in options" routes to .signInOptions. See Authentication for the full flow.
The signed-in branch: a router-driven TabView
The authenticated shell is a TabView built by iterating AppTab.allCases. Each tab gets its own NavigationStack bound to that tab's path on the router, so back-stacks are independent per tab:
private var authenticatedView: some View {
TabView(selection: $router.selectedTab) {
ForEach(AppTab.allCases) { tab in
NavigationStack(path: $router[tab]) {
tabRootView(for: tab)
.navigationDestination(for: AppDestination.self) { destination in
destinationView(for: destination)
}
}
.tabItem {
Label(tab.rawValue, systemImage: tab.icon)
}
.tag(tab)
}
}
.onAppear {
incrementProcessCount()
Task { await authenticateAPI() }
}
.toolbar {
Button("Sign out") { signOutUser() }
}
}
The three tabs
AppTab (in ShipThatApp/Navigation/AppRouterTypes.swift) defines the tabs, their titles, and their SF Symbols in one place:
enum AppTab: String, TabType {
case welcome = "Welcome"
case content = "Content"
case settings = "Settings"
var icon: String {
switch self {
case .welcome: return "house"
case .content: return "doc.richtext"
case .settings: return "gear"
}
}
}
Each tab maps to a root view:
| Tab | Root view | Role |
|---|---|---|
| Welcome | WelcomeScreen | A Lottie launch animation plus a "Welcome Back!" greeting that pulls the user's name from @AppStorage. Your home/dashboard. |
| Content | ContentScreenView | The feature menu — an insetGrouped List linking to every demo (AI, paywalls, animations). |
| Settings | SettingsView | Profile, preferences, Pro/subscription, and account deletion. |
The Content tab is your feature directory
ContentScreenView is the showcase menu. Every row calls router.navigateTo(...) instead of wrapping a NavigationLink, which keeps navigation centralized:
struct ContentScreenView: View {
@Environment(AppRouter.self) private var router
var body: some View {
List {
Section(header: Text("AI")) {
Button { router.navigateTo(.pokedex) } label: {
ListRow(icon: "camera.viewfinder",
title: "Dex Scanner",
detail: "Scan & identify anything")
}
.buttonStyle(.plain)
// ...chat, single request, generate image, vision
}
Section(header: Text("Paywalls")) { /* paywall1/2/3 */ }
Section(header: Text("Animations")) { /* lottie1/2/3 */ }
}
.listStyle(.insetGrouped)
}
}
From here, rows route to the Dex Scanner, AI Chat, one-off requests, image generation, Vision, the three paywall styles, and the Lottie demos.
Typed destinations and sheets
LandingView translates router enum cases into concrete views in two @ViewBuilder switches. AppDestination covers pushes; AppSheet covers modals:
@ViewBuilder
private func destinationView(for destination: AppDestination) -> some View {
switch destination {
case .signInOptions: SignInOptions()
case .chat: ChatView()
case .singleRequest: SingleRequestView()
case .generateImage: GenImageView()
case .vision: VisionView()
case .pokedex: PokedexHomeView()
case .paywall1: Paywall1View()
case .paywall2: Paywall2View()
case .paywall3: Paywall3View()
case .lottie1: Lottie1View()
case .lottie2: Lottie2View()
case .lottie3: Lottie3View()
}
}
@ViewBuilder
private func sheetView(for sheet: AppSheet) -> some View {
switch sheet {
case .paywall: Paywall1View()
case .feedbackForm: FeedbackView()
case .editProfile: EditProfileView()
}
}
Because these are exhaustive switches over enums, adding a screen is a compile-time-checked change — Swift forces you to handle the new case everywhere.
Deep links and the magic-link callback
.onOpenURL first intercepts the Supabase magic-link callback, then defers anything else to the router's URL parser:
private func handleIncomingURL(_ url: URL) {
// Auth callback: shipthatapp://login-callback?...
if url.scheme == "shipthatapp" && url.host == "login-callback" {
Task {
do {
_ = try await authManager.getSessionFromUrl(url: url)
} catch {
Logger.viewCycle.error("Error setting session: \(error)")
}
}
return
}
// Everything else: let AppRouter map the path to a destination.
router.navigate(to: url)
}
AppDestination.from(path:fullPath:parameters:) maps URL paths like pokedex, chat, or paywall2 to destinations, so shipthatapp://pokedex opens the Dex Scanner. See Authentication for the magic-link half.
Quick actions and review prompts
LandingView is also where two lifecycle features live:
- Quick actions. When the app becomes active,
checkQuickActions()readsquickActionService.actionand, for.feedbackForm, presents the feedback sheet viarouter.presentSheet(.feedbackForm), then clears the action. - Review prompts.
incrementProcessCount()bumps a counter onSettingsManagereach time the authed shell appears; once it crossesConfig.Purchases.minAppRunsBeforeReviewReq(and the version hasn't been prompted yet), it requests an App Store review after a short delay and logs a TelemetryDeck event. See Review Requests.
Customize / extend it
- Add a tab. Add a case to
AppTabwith its title andicon, then add it totabRootView(for:). TheForEach(AppTab.allCases)loop renders it automatically with its own navigation stack. - Add a screen. Add a case to
AppDestination, handle it indestinationView(for:), and (optionally) map a URL path inAppDestination.from(...)for deep linking. Navigate to it withrouter.navigateTo(.yourCase)from any view that has@Environment(AppRouter.self). - Add a modal. Add a case to
AppSheet, handle it insheetView(for:), and present it withrouter.presentSheet(.yourSheet). - Reskin the Welcome tab.
WelcomeScreenis intentionally minimal (Lottie + name). Replace it with your real dashboard — the rest of the shell doesn't care.
Navigate from anywhere, never inline
The rule across ShipThatApp: grab the router with @Environment(AppRouter.self) private var router and call router.navigateTo(...) / router.presentSheet(...). Avoid scattering NavigationLink(destination:) through feature views — keeping every route in AppRouterTypes.swift is what makes deep linking and tab-jumping work.
Related
- ContentView — the onboarding gate that mounts
LandingView. - Authentication & AuthManager — the session that drives the auth fork and the magic-link callback.
- Sign In — the root of the unauthenticated branch.
- Settings — the Settings tab.
- Review Requests — the quick-action and review-prompt logic that lives here.
- Dex Scanner, AI Chat, Image Generation, Vision — destinations reachable from the Content tab.