high priority medium complexity frontend pending frontend specialist Tier 3

Acceptance Criteria

Every interactive element in ReceiptCameraSheet has a non-empty Semantics label readable by VoiceOver (iOS) and TalkBack (Android)
'Take photo with camera' button announces its label and role ('button') when focused by screen reader
'Choose from photo library' button announces its label and role ('button') when focused by screen reader
When the sheet opens, screen reader focus moves automatically to the first interactive element (camera button) — verified via FocusScope and SemanticsNode traversal
Focus is trapped within the sheet while it is open — tabbing/swiping does not move focus to the content beneath the sheet
On dismissal (any mechanism), focus is restored to the trigger element that opened the sheet
Attachment state is communicated via Semantics: 'Attach receipt, no receipt attached' when no receipt is attached, 'Receipt attached' when a receipt is present
All contrast ratios for text and icon elements within the sheet meet WCAG 2.2 AA minimum (4.5:1 for normal text, 3:1 for large text and UI components)
Sheet dismiss action is announced by screen reader ('Double-tap to dismiss') via ExcludeSemantics or equivalent for backdrop, not a confusing traversal target
Accessibility labels are localisation-ready (use string constants, not hardcoded literals)

Technical Requirements

frameworks
Flutter
flutter_test
apis
Flutter Semantics API
FocusScope
FocusNode
SemanticsProperties
data models
accessibility_preferences
performance requirements
Semantics tree update must not cause additional frame rebuilds beyond the normal widget rebuild cycle
security requirements
Screen reader announcements must not leak any PII (no file paths, no contact data)
ui components
Semantics widget wrappers for all interactive elements
MergeSemantics where button icon and label are separate widgets
ExcludeSemantics for purely decorative elements

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Use the Semantics widget with label, hint, and button: true properties rather than relying on implicit semantics from InkWell alone — InkWell semantics are sometimes insufficient for complex layouts. For focus trapping, wrap the sheet content in a FocusScope with a canRequestFocus scope; use autofocus: true on the first button. For focus restoration, capture the FocusNode of the trigger before opening the sheet and call requestFocus() on it in the onClosed callback of showModalBottomSheet. The accessibility_preferences data model (font_scale_factor, contrast_mode) should inform styling but is consumed at the app theme level, not within this widget directly.

Avoid hardcoded announcement strings — define them in a SemanticLabels constants file for reuse and future localisation. Test with both VoiceOver (iOS) and TalkBack (Android) reading direction (RTL support may be relevant for future localisation).

Testing Requirements

Widget tests using flutter_test with SemanticsHandle: verify each interactive element has the correct semantics label and flags (isButton: true); verify Semantics node for attachment state reflects 'no receipt attached' and 'receipt attached' states; verify ExcludeSemantics applied to decorative dividers and backdrop. Use tester.getSemantics() to assert SemanticsNode properties. Manually verify VoiceOver traversal order on a physical iOS device and TalkBack on Android before marking complete — automated tests cannot fully validate real assistive technology behaviour.

Component
Receipt Camera Sheet
ui medium
Epic Risks (3)
high impact medium prob technical

Non-blocking upload creates a race condition: if the claim record is submitted and saved before the upload completes, the storage path may never be written to the claim_receipts table, leaving the claim with a missing receipt that was nonetheless required.

Mitigation & Contingency

Mitigation: Design the attachment service to queue a completion callback that writes the storage path to the claim record upon upload completion, even after the claim form has submitted. Use a local task queue with persistence to survive app backgrounding. Test the race condition explicitly with simulated slow uploads.

Contingency: If the async path association proves unreliable, fall back to blocking upload before claim submission with a clear progress indicator, accepting the UX trade-off in exchange for data integrity.

high impact medium prob scope

The offline capture requirement (cache locally, sync when connected) significantly increases state management complexity. If the offline queue is not durable, receipts captured without connectivity may be lost when the app is killed, causing claim submission failures users are not aware of.

Mitigation & Contingency

Mitigation: Persist the offline upload queue to local storage (e.g., Hive or SQLite) on every state transition. Implement background sync using WorkManager (Android) and BGTaskScheduler (iOS). Scope the initial delivery to online-only flow if offline sync cannot be adequately tested before release.

Contingency: Ship without offline support in the first release, displaying a clear 'Upload requires connection' message. Add offline sync as a follow-on task once the core online flow is validated in production.

medium impact low prob integration

The inline bottom sheet presentation within a multi-step wizard can conflict with existing modal navigation and back-button handling, particularly if the expense wizard itself uses nested navigation or custom route management.

Mitigation & Contingency

Mitigation: Review the expense wizard navigation architecture before implementation. Use showModalBottomSheet with barrier dismissal disabled to prevent accidental dismissal. Coordinate with the expense wizard team on modal stacking behavior and ensure the camera sheet does not interfere with wizard step transitions.

Contingency: If modal stacking causes navigation issues, present the camera sheet as a full-screen dialog using PageRouteBuilder with a transparent barrier, preserving wizard state via the existing Bloc while still appearing inline.