critical priority high complexity backend pending backend specialist Tier 2

Acceptance Criteria

attachReceiptToClaim(claimId, imageFile) returns a Stream<UploadProgress> immediately without blocking the caller
The stream emits UploadProgress.compressing while ReceiptImageCompressor is running
The stream emits UploadProgress.uploading(percentage) with values 0–100 during Supabase Storage upload
The stream emits UploadProgress.completed(receiptUrl) as the final event on success
The stream emits UploadProgress.failed(failure) as the final event on any unrecoverable error
ClaimReceiptRepository.linkReceiptToClaim is called only after successful storage upload
If compression fails, the stream emits failed immediately without attempting upload
If the Supabase upload succeeds but ClaimReceiptRepository linking fails, the orphaned storage object is deleted (compensating action)
Concurrent calls with different claimIds are independent and do not interfere
Service is injectable and all three collaborators (Compressor, StorageRepository, ClaimRepository) are mockable
Unit tests achieve 90%+ branch coverage including error paths

Technical Requirements

frameworks
Flutter
Riverpod
Supabase Flutter SDK
apis
Supabase Storage API (upload with progress)
Supabase PostgREST (claim_receipts table insert)
data models
UploadProgress (sealed: compressing | uploading(int) | completed(String) | failed(Failure))
ClaimReceipt (claimId, storageUrl, uploadedAt, fileSizeBytes)
ReceiptStorageRepository (interface)
ClaimReceiptRepository (interface)
performance requirements
Stream must emit the first event within 100 ms of the call (compression start is immediate)
Memory usage during upload must not exceed compressed file size × 2 (no double-buffering)
security requirements
Storage bucket path must include the authenticated user's ID to prevent cross-user access: receipts/{userId}/{claimId}/{timestamp}.jpg
Supabase RLS policies must be in place on the claim_receipts table — verify policy exists before implementing insert logic
Compressed bytes must not be written to disk as a temp file unless the Storage SDK requires it

Execution Context

Execution Tier
Tier 2

Tier 2 - 518 tasks

Can start after Tier 1 completes

Implementation Notes

Use a StreamController.broadcast() internally so multiple UI listeners can attach (e.g., both the upload indicator and the form submit button). The orchestration sequence is: (1) add compressing event, (2) await compressor — on error, add failed and close; (3) add uploading(0), (4) call StorageRepository.upload(bytes, path, onProgress: (pct) => add(uploading(pct))); on error, add failed and close; (5) call ClaimReceiptRepository.link — on error, call StorageRepository.delete(path), add failed and close; (6) add completed(url) and close. Do NOT use try/catch on the entire block — handle each step's failure explicitly to enable the compensating delete. Expose the stream from a Riverpod AsyncNotifier or a plain service class depending on the BLoC/Riverpod conventions already used in the project.

Keep this class thin — it is an orchestrator, not a business logic owner.

Testing Requirements

Unit tests (flutter_test): inject mock Compressor, StorageRepository, and ClaimRepository. Test happy path stream sequence (compressing → uploading(50) → uploading(100) → completed). Test compression failure path (stream emits failed, no upload attempted). Test upload failure path (stream emits failed, ClaimRepository not called).

Test ClaimRepository failure with compensating delete called on StorageRepository. Use StreamMatcher or collect all stream events via stream.toList() and assert order and types. Integration test: run against a Supabase test project with a real small JPEG and assert the file appears in storage and the claim_receipts row is created.

Component
Receipt Attachment Service
service high
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.