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.currentSession is nil, the unauthenticated stack shows SignInView. When a session exists, the tab bar appears. Because currentSession lives on an @Observable manager, 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 calling router.presentSheet(...).
  • Deep links + quick actions. .onOpenURL routes 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:

TabRoot viewRole
WelcomeWelcomeScreenA Lottie launch animation plus a "Welcome Back!" greeting that pulls the user's name from @AppStorage. Your home/dashboard.
ContentContentScreenViewThe feature menu — an insetGrouped List linking to every demo (AI, paywalls, animations).
SettingsSettingsViewProfile, 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.

.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() reads quickActionService.action and, for .feedbackForm, presents the feedback sheet via router.presentSheet(.feedbackForm), then clears the action.
  • Review prompts. incrementProcessCount() bumps a counter on SettingsManager each time the authed shell appears; once it crosses Config.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 AppTab with its title and icon, then add it to tabRootView(for:). The ForEach(AppTab.allCases) loop renders it automatically with its own navigation stack.
  • Add a screen. Add a case to AppDestination, handle it in destinationView(for:), and (optionally) map a URL path in AppDestination.from(...) for deep linking. Navigate to it with router.navigateTo(.yourCase) from any view that has @Environment(AppRouter.self).
  • Add a modal. Add a case to AppSheet, handle it in sheetView(for:), and present it with router.presentSheet(.yourSheet).
  • Reskin the Welcome tab. WelcomeScreen is 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.

Previous
SignInView