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:
| Secret | Effect |
|---|---|
MIN_APP_VERSION | Below it → force update: a non-dismissable bottom sheet |
LATEST_APP_VERSION | Below 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).
Consent
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. dropBiometricGateif you don't want app-lock, or moveUpdateGateaboveBiometricGate. 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(aUserDefaultswrapper), so adding your own gate follows the same pattern: a persisted flag + a conditional view.
