Implement non-blocking upload with progress streaming
epic-receipt-capture-and-attachment-core-logic-task-008 — Extend ReceiptAttachmentService to perform Supabase Storage uploads in a background isolate, emitting upload progress events via StreamController. The expense form must remain interactive and submittable during upload. Implement upload cancellation, retry on transient network errors (max 3 attempts with exponential backoff), and final completion/failure events.
Acceptance Criteria
Technical Requirements
Execution Context
Tier 3 - 413 tasks
Can start after Tier 2 completes
Implementation Notes
Flutter isolates cannot share objects — you must pass the compressed Uint8List and auth token as primitive-compatible messages to the isolate via SendPort/ReceivePort. The Supabase Flutter SDK's storage uploadBinary method accepts an onUploadProgress callback — call this from the isolate and send progress integers back via the ReceivePort. For retry logic, implement a RetryPolicy value object with maxAttempts and baseDelayMs; the exponential delay is baseDelayMs * 2^(attempt-1). Distinguish transient errors (SocketException, TimeoutException, HTTP 503/429) from permanent ones (HTTP 400/401/403/404) before deciding to retry.
For cancellation, use an IsolateCancel flag: the isolate checks it between retries and during the progress callback; on cancel, it deletes the partial upload and sends a cancelled message. Note that Supabase Storage does not support true resumable uploads via the Flutter SDK as of 2025 — cancellation deletes and the next call starts fresh. Expose cancelUpload(claimId) from the service class, keyed by claimId so concurrent uploads can be cancelled independently.
Testing Requirements
Unit tests (flutter_test): mock the Supabase Storage client within the isolate by injecting a factory. Test retry logic: simulate SocketException on attempt 1 and 2, success on attempt 3 — assert three upload calls and correct backoff delays using fake async. Test exhausted retries: three consecutive SocketExceptions — assert UploadProgress.failed. Test cancellation: cancel mid-upload — assert cancelled event and StorageRepository.delete called.
Test permanent error (403): assert no retry, immediate failed. Integration test: upload a real 400 KB JPEG to a Supabase test bucket via the service, assert progress events received, file present in bucket, and claim_receipts row linked. Use flutter_test's FakeAsync to test backoff timing without real delays.
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.
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.
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.