critical priority medium complexity integration pending integration specialist Tier 1

Acceptance Criteria

BiometricAuthService.authenticate() is called automatically in initState (or a post-frame callback from initState) when the overlay mounts — the user sees the native biometric sheet without tapping a button
On authentication success: overlay is dismissed and the onAuthSuccess callback is invoked, returning the user to the app
On authentication failure (wrong fingerprint/face): a retry option is shown on the overlay with a localized 'Try again' button; BiometricAuthService.authenticate() is called again on tap
On authentication fallback (user selects 'Use password' in the native dialog, or LocalAuthFailure.notEnrolled / .lockedOut): overlay routes to credential login via the onFallbackTapped callback
Maximum 3 retry attempts before automatically routing to credential login to prevent indefinite retry loops
A loading indicator (using design token spinner) is shown while the native biometric dialog is open or authentication is in progress
Face ID is correctly triggered on iOS devices with Face ID; Touch ID on older iPhones; fingerprint on Android — the BiometricAuthService abstraction handles this transparently
The widget handles the case where authenticate() throws an unexpected exception by logging the error (debug only) and routing to credential login

Technical Requirements

frameworks
Flutter
Dart
Riverpod
apis
flutter_local_auth (iOS LocalAuthentication / Android BiometricPrompt)
performance requirements
Native biometric dialog must appear within 300ms of overlay mount — use addPostFrameCallback to call authenticate() immediately after first frame
Retry state updates must not cause full widget rebuild — use setState on only the retry-count and loading-state fields
security requirements
BiometricAuthService must be the only entry point to native biometric APIs — no direct flutter_local_auth calls inside the widget
Authentication result must not be logged — only LocalAuthFailure codes may be logged at debug level
After 3 failed attempts, the overlay must be dismissed and the user routed to credential login — do not allow infinite retries which could indicate an attack
Biometric data never leaves the device — the result is a boolean pass/fail from the OS; document this in a code comment for auditors
ui components
Loading spinner (design token, shown during auth in progress)
Retry button (design token AppButton, shown after failure)
Failure message text (localized, design token typography)
Attempt counter display (optional, subtle — '1 of 3 attempts')

Execution Context

Execution Tier
Tier 1

Tier 1 - 540 tasks

Can start after Tier 0 completes

Implementation Notes

Inject BiometricAuthService via the constructor (or Riverpod ref.read in initState if the service is already in the Riverpod graph) — do not use a static instance. Use `WidgetsBinding.instance.addPostFrameCallback` inside initState to trigger the first authentication attempt after the overlay is fully rendered; calling authenticate() directly in initState can cause issues if the widget tree is not yet laid out. Model widget state with a private `_BiometricOverlayState` enum: `idle`, `authenticating`, `failed`, `maxAttemptsReached` — use `setState` to transition between them. For the retry counter, use a simple `int _attemptCount` field.

The fallback button (from task-004) should also call onFallbackTapped — wire it in this task. Ensure that if the widget is disposed while authenticate() is in progress (e.g. user force-quits), the future result is discarded safely using a `_mounted` guard (`if (!mounted) return`) before calling setState or callbacks.

Testing Requirements

Widget tests using flutter_test with a mocked BiometricAuthService. Test cases: (1) authenticate() called on mount (verify mock receives exactly one call); (2) success result → onAuthSuccess callback fired and widget popped; (3) failure result → retry button appears; (4) retry button tap → authenticate() called again; (5) 3 failures → onFallbackTapped callback fired automatically; (6) fallback result from service → onFallbackTapped fired immediately; (7) authenticate() throws exception → onFallbackTapped fired; (8) loading indicator visible while authenticate() future is pending (use Completer to hold future). Integration test on a physical device (TestFlight build): verify Face ID dialog appears on app resume, success returns to app, cancel routes to login.

Component
Biometric Prompt Overlay
ui low
Epic Risks (3)
medium impact high prob technical

Flutter's AppLifecycleState.resumed event fires in scenarios beyond simple user-initiated app-switching: it also fires when the native biometric dialog itself dismisses and returns control to Flutter, when system alerts (low battery, notifications) temporarily cover the app, and when the app is foregrounded by a deep link. Without careful debouncing and state tracking, SessionResumeManager can trigger multiple overlapping biometric prompts or prompt immediately after a just-completed authentication, creating a confusing loop.

Mitigation & Contingency

Mitigation: Implement a state machine inside SessionResumeManager with explicit states (idle, prompting, authenticated, awaiting-fallback) and guard all prompt triggers with a state check. Add a minimum inter-prompt interval of 3 seconds. Write widget tests that simulate rapid lifecycle event sequences and verify only one prompt is shown.

Contingency: If the state machine approach proves difficult to test or maintain, replace it with a simple boolean isPromptActive flag with a debounce timer, accepting slightly less precise semantics in exchange for simpler reasoning about concurrent lifecycle events.

high impact medium prob technical

The BiometricPromptOverlay appears on top of the existing app content when the app resumes. If focus is not correctly transferred to the overlay and returned to the underlying screen after authentication, screen reader users (VoiceOver/TalkBack) will either be unable to interact with the prompt or will lose their navigation position in the app after authentication completes, violating WCAG 2.2 focus management requirements and creating a broken experience for Blindeforbundet users.

Mitigation & Contingency

Mitigation: Use Flutter's FocusScope and FocusTrap utilities to capture focus within the overlay on presentation and restore the previous FocusNode after dismissal. Add a live region announcement (using the accessibility live region announcer component) when the overlay appears. Include dedicated VoiceOver and TalkBack test cases in the acceptance criteria.

Contingency: If FocusTrap behaviour proves unreliable across Flutter versions, implement the overlay as a full Navigator push to a modal route rather than an overlay widget, which gives Flutter's built-in modal semantics and focus management automatic WCAG-compliant behaviour.

low impact high prob technical

The BiometricUnavailableBanner needs to deep-link to biometric enrollment settings on both iOS and Android. iOS uses a single URL scheme (app-settings:) that opens the app's settings page. Android has no universal URL for biometric settings — the correct Intent action (Settings.ACTION_BIOMETRIC_ENROLL) was introduced in API 30, with different fallback actions required for API 23-29. Using the wrong action on older Android devices either crashes or navigates to an unrelated settings screen.

Mitigation & Contingency

Mitigation: Build an Android API version check into LocalAuthIntegration that selects the correct Intent action based on the runtime SDK version. Test against Android API 23, 28, and 30+ in the CI matrix. For iOS, validate that the app-settings: URL scheme is correctly declared.

Contingency: If the Android settings Intent fragmentation cannot be resolved reliably for all target API levels, fall back to navigating to the top-level Settings screen (Settings.ACTION_SETTINGS) with an overlay instruction telling the user to navigate to 'Security > Biometrics' manually, ensuring the user always has a path to resolve the issue even if the deep link is imprecise.