high priority low complexity frontend pending frontend specialist Tier 3

Acceptance Criteria

Swipe-down gesture on the DraggableScrollableSheet dismisses the sheet and returns null to the caller
Tapping the backdrop (area outside the sheet) dismisses the sheet and returns null to the caller
Pressing the Android system back button dismisses the sheet and returns null to the caller (no app navigation change)
Tapping a Cancel button within the sheet dismisses the sheet and returns null to the caller
When an upload is actively in progress (ReceiptAttachmentService is in uploading state), any dismissal attempt shows a confirmation dialog: 'Cancel upload? Your receipt will not be saved.' with 'Stay' and 'Cancel upload' actions
Selecting 'Stay' in the confirmation dialog restores full sheet focus and continues the upload without interruption
Selecting 'Cancel upload' in the confirmation dialog cancels the in-progress upload via ReceiptAttachmentService, dismisses the sheet, and returns null
After successful image selection (not during upload), the sheet dismisses immediately without a confirmation dialog
No upload is triggered as a side effect of dismissal — dismissal is purely a UI event
WillPopScope (or PopScope for Flutter 3.16+) correctly intercepts back navigation on Android in all dismissal scenarios

Technical Requirements

frameworks
Flutter
apis
WillPopScope / PopScope (Flutter 3.16+)
showDialog
ReceiptAttachmentService (upload state stream)
performance requirements
Confirmation dialog must appear within one frame of the dismissal gesture being detected
security requirements
Cancelled uploads must result in any partially uploaded data being cleaned up via ReceiptAttachmentService.cancelUpload() — no orphaned files in Supabase Storage
ui components
AlertDialog or custom modal for upload cancellation confirmation
AppButton variants for 'Stay' (primary) and 'Cancel upload' (destructive/secondary)

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Check Flutter version targeting: use PopScope with canPop and onPopInvoked for Flutter 3.16+ — WillPopScope is deprecated. The upload state should be read from ReceiptAttachmentService's stream (or Riverpod/BLoC state) — do not duplicate state within this widget. The confirmation dialog should be a simple AlertDialog rather than a custom bottom sheet to avoid nested modal complexity. Ensure that barrierDismissible: false is set on showModalBottomSheet when upload is in progress so the user cannot bypass the confirmation by tapping the backdrop — this requires dynamically controlling the sheet's barrier behaviour, which may require replacing the standard sheet with a custom overlay if Flutter's showModalBottomSheet does not support dynamic barrierDismissible.

Consider using a StatefulWidget with an isUploading flag synced from the service stream. Clean cancellation: call ReceiptAttachmentService.cancelUpload() before Navigator.pop() to guarantee no orphaned Supabase Storage objects.

Testing Requirements

Widget tests: verify swipe-down returns null; verify backdrop tap returns null; verify Cancel button returns null; verify back button returns null; verify confirmation dialog appears when upload is in progress; verify 'Stay' resumes upload state; verify 'Cancel upload' calls ReceiptAttachmentService.cancelUpload() and returns null. Use mocktail to mock ReceiptAttachmentService and control the upload state stream. Test both PopScope (Flutter 3.16+) and WillPopScope fallback if supporting older Flutter versions.

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.