App Gates

The launch experience: error boundary, biometric lock, update, onboarding, consent.

Before the main shell renders, the app passes through a stack of gates — composable views that each decide whether to show their child or interrupt with their own UI. This is the launch experience: crash recovery, app-lock, update prompts, onboarding, and legal consent, in a fixed order. Each is Integrated and driven by AppPreferences + AppSecrets.

The gate stack

RootView nests the gates outside-in — the outermost runs first:

ErrorBoundary {                       // global crash/error fallback
  BiometricGate(initialLocked: ) {   // Face ID / Touch ID app-lock
    UpdateGate {                      // force / soft update prompts
      OnboardingGate {                // first-run slides
        ConsentGate {                 // legal acceptance → starts analytics
          AuthGate()                  // sign-in vs main shell
        }
      }
    }
  }
}

Each gate reads persisted state, so returning users fall straight through to AuthGate. The Rating prompt isn't a gate — it's a smart trigger fired from inside the app (below).

Key files

Error boundary

ErrorBoundary wraps everything (mounted outside BiometricGate so errors always surface). View models post unrecoverable errors to the global AppErrorBus.shared.post(...) — no DI threading — and the boundary overlays a full-screen "Something went wrong" card with Try again (clears the bus) and Send a report (captures Sentry user feedback). Stack-trace detail renders in DEBUG only. See Observability.

Biometric lock

BiometricGate locks the app behind Face ID / Touch ID / Optic ID when AppPreferences.biometricEnabled is on (toggled from Account Security). Behavior:

  • First launch while enabled → locked; Unlock runs LAContext.evaluatePolicy.
  • Backgrounding stamps a timestamp; re-activating after more than the 1-second grace period re-locks and re-prompts.
  • While scenePhase != .active, an opaque privacy overlay hides content from the app-switcher snapshot.
  • Cancellation leaves the lock in place; the actual session secret lives in the Keychain (this flag only controls locking).

Update gate

UpdateController compares the running build's CFBundleShortVersionString against two secrets and drives UpdateGate:

SecretEffect
MIN_APP_VERSIONBelow it → force update: a non-dismissable bottom sheet
LATEST_APP_VERSIONBelow it → soft update: a dismissable alert, dismissal persisted per-version

Both actions open APP_STORE_URL in Safari (iOS has no in-app update API). Version compare is numeric and component-wise (1.0 < 1.0.1 < 1.2).

Test it locally without shipping a build: bump LATEST_APP_VERSION (soft) or MIN_APP_VERSION (force) in Secrets.xcconfig above your current version and relaunch. In DEBUG, TestView can also force a gate state directly.

Onboarding

OnboardingGate shows a 4-slide TabView (OnboardingView) with skip / next / page dots until AppPreferences.onboardingCompleted is set. Completion fires an onboarding completed analytics event and records a Rating action. It does not auto-route to the Permissions screen (that's opt-in from the Me tab).

ConsentGate blocks everything below it until AppPreferences.acceptedConsentVersion == LegalContent.version (a dated version string, e.g. 2026-04-30 — bumping it re-prompts everyone). It shows the bundled legal documents inline and, critically, starts analytics only after acceptance:

// ConsentGate — analytics never fires before consent
content.task { env.analytics.start() }

The bundled Terms / Privacy / EULA render from local content; "view online" links use TERMS_URL / PRIVACY_URL / EULA_URL when set. Placing consent above the analytics start is what keeps the app App-Store-review-safe.

Rating (smart trigger, not a gate)

RatingController.recordAction(_:) requests an in-app review via AppStore.requestReview(in:) only when all three conditions hold: ≥ 3 recorded actions since the last prompt, not already prompted on the current app version, and ≥ 90 days since the last prompt. Call it from natural success moments (e.g. onboarding completed, a todo cleared). Apple independently caps prompts at 3 per 365 days and may suppress the UI.

Customizing the stack

  • Reorder / remove a gate by editing the nesting in RootView — e.g. drop BiometricGate if you don't want app-lock, or move UpdateGate above BiometricGate. Each gate is self-contained and reads its own state.
  • Disable a gate at runtime by leaving its driving state/secret unset: onboarding hides once completed, consent once accepted, update stays idle with no MIN/LATEST_APP_VERSION, biometric stays open with the toggle off.
  • Gates read/write through AppPreferences (a UserDefaults wrapper), so adding your own gate follows the same pattern: a persisted flag + a conditional view.

On this page