critical priority medium complexity backend pending backend specialist Tier 3

Acceptance Criteria

When `AppLifecycleState.resumed` fires and the in-memory state was last updated more than 5 minutes ago, the notifier reloads snapshots from the repository
When `AppLifecycleState.resumed` fires and the state is fresh (< 5 minutes), no repository read is performed
On logout event (auth state transitions to unauthenticated): `repository.clearAll()` is called, then in-memory state resets to `TabState(activeTabIndex: 0, snapshots: [empty × 5])`
On role-switch event (auth state changes role while remaining authenticated): same clearAll + reset behavior as logout
Subscriptions to `AppLifecycleState` and auth state are established in `build()` and cancelled via `ref.onDispose` — no subscription leaks
The notifier uses `WidgetsBindingObserver` or `AppLifecycleListener` (Flutter 3.13+) — not a polling mechanism
Auth state stream is consumed via the existing Riverpod auth provider — no direct Supabase client access in this notifier
After a reset, the first UI frame shows tab index 0 (Home) with no sub-routes — GoRouter is notified via the notifier state change
Lifecycle hooks do not run during widget tests unless explicitly triggered — use `ref.read(appLifecycleProvider)` to allow override in tests
WCAG 2.2 AA: after role-switch, focus is placed on the Home tab's first interactive element — the notifier emits a dedicated event or the UI observes the reset and calls `SemanticsService.announce('Session updated, returning to home')`

Technical Requirements

frameworks
Flutter
Riverpod
Dart
Supabase (via auth provider abstraction)
apis
AppLifecycleListener (Flutter 3.13+) or WidgetsBindingObserver for lifecycle events
Auth state stream from existing authStateProvider (Supabase-backed)
NavigationStateRepository.clearAll()
data models
TabState
NavigationStackSnapshot
AuthState (role field)
performance requirements
Repository clearAll must complete in under 50ms (local SQLite or shared_preferences — no network call)
Auth stream subscription must not block the UI thread — use `ref.listen` which runs on the event loop
security requirements
On logout, all navigation state (including any snapshot containing browsed contact paths) must be cleared before the auth session is fully terminated
Role-switch must invalidate state before the new role's UI is rendered — prevent brief flash of previous role's navigation state
BankID / Vipps session termination must trigger the logout hook — ensure the auth provider emits an unauthenticated event on BankID logout

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Use `ref.listen(authStateProvider, (previous, next) { ... })` inside `build()` to subscribe to auth changes — this is automatically disposed when the provider is disposed. For `AppLifecycleState`, use `AppLifecycleListener(onResume: _onResume)` registered in `build()` and disposed in `ref.onDispose(() => _lifecycleListener.dispose())`. Staleness check: store a `DateTime _lastMutatedAt` field in the notifier and compare on resume.

The 5-minute threshold should be a named constant `_staleThreshold = Duration(minutes: 5)`. For the logout/role-switch reset, use a single `_resetState()` private method that calls `clearAll` and then `state = AsyncData(_defaultTabState)` — reuse this for both triggers. The BankID and Vipps logout paths must go through the same auth provider so the stream emits correctly — coordinate with the auth epic to ensure this contract. Do not call `GoRouter.go()` from the notifier — let the shell widget observe `activeTabIndex` via `ref.watch` and call `shellNavigator.goBranch(0)` reactively.

Testing Requirements

Unit tests in `test/navigation/tab_state_manager_lifecycle_test.dart` using `ProviderContainer` with fake providers: (1) Emit `AppLifecycleState.resumed` with stale timestamp (> 5 min) — assert repository read was called and state was refreshed. (2) Emit `AppLifecycleState.resumed` with fresh timestamp (< 5 min) — assert repository read was NOT called. (3) Emit logout auth event — assert `repository.clearAll()` called and `state.activeTabIndex == 0` and all snapshots are empty. (4) Emit role-switch auth event — assert same clearAll + reset behavior as logout.

(5) Dispose the notifier — assert no further events are processed after disposal (subscription leak test using a broadcast stream controller). (6) Emit logout during active `updateStackSnapshot` call — assert final state is the reset state, not a race-condition hybrid. Target: 90%+ line coverage, all race conditions tested.

Component
Tab State Manager
service medium
Epic Risks (2)
high impact medium prob technical

StatefulShellRoute branch navigator state can interact unexpectedly with GoRouter's imperative navigation (go, push, replace), causing state snapshots to desync from actual route stacks. This could manifest as a user returning to a tab and seeing a different screen than expected, breaking the core motor-fatigue promise.

Mitigation & Contingency

Mitigation: Write integration tests that simulate cross-tab navigation with nested pushes before any UI layer is built. Pin go_router to a tested minor version and review the StatefulShellRoute changelog before upgrading.

Contingency: If branch navigator state consistently desyncs, fall back to a manual stack snapshot strategy using a custom NavigatorObserver that records and replays navigation events independently of StatefulShellRoute internals.

medium impact medium prob technical

Persisted navigation stacks in shared_preferences can become stale or corrupt if route paths are renamed during development, causing app crashes or infinite redirect loops on cold start for users who have an old snapshot.

Mitigation & Contingency

Mitigation: Version the persisted schema with a format key. On app start, validate that all stored route paths exist in the current route config before restoring; silently discard invalid entries rather than crashing.

Contingency: Implement a safe-mode cold start that skips state restoration after a detected crash (via a dirty-launch flag written at startup and cleared on successful first frame), falling back to the default root tab.