critical priority medium complexity frontend pending frontend specialist Tier 4

Acceptance Criteria

Widget renders in idle state showing an 'Attach receipt' button when no receipt is attached and upload is not in progress
Widget renders in uploading state showing a linear progress bar and a percentage label (e.g., '47%') when ReceiptAttachmentService emits an upload progress event
Widget renders in uploaded state showing a thumbnail of the uploaded receipt image and a 'Remove' button when upload completes successfully
Widget renders in failed state showing a retry button and a short error message (e.g., 'Upload failed. Tap to retry.') when ReceiptAttachmentService emits an error event
Widget transitions between states reactively as ReceiptAttachmentService stream emits new states — no manual refresh required
When ReceiptThresholdValidator determines the expense amount exceeds 100 kr and no receipt is attached, the idle state displays a mandatory indicator (e.g., red asterisk or 'Required' label) adjacent to the attach button
Tapping the 'Remove' button in uploaded state triggers ReceiptAttachmentService.removeAttachment() and transitions back to idle state
Tapping the retry button in failed state re-triggers the upload via ReceiptAttachmentService.retryUpload()
Thumbnail in uploaded state loads asynchronously and shows a placeholder until loaded; broken image URLs show a fallback icon
All state transitions are animated (fade or slide) with a duration not exceeding 200ms

Technical Requirements

frameworks
Flutter
Riverpod or BLoC
apis
ReceiptAttachmentService (stream/state)
ReceiptThresholdValidator (internal)
Supabase Storage (signed URL for thumbnail)
performance requirements
Progress bar updates must render at minimum 30fps during upload without dropping frames in the surrounding form
Thumbnail must load within 2 seconds on a standard 4G connection; placeholder shown until then
security requirements
Thumbnail URL must be a Supabase signed URL with expiry — never a public URL for receipt images stored in the private bucket
Signed URL must not be logged or exposed in error messages
ui components
LinearProgressIndicator (themed with design tokens)
CachedNetworkImage or FadeInImage for thumbnail
AppButton variants: attach (secondary), remove (destructive text), retry (secondary)
Badge or label for mandatory indicator
AnimatedSwitcher for state transitions

Execution Context

Execution Tier
Tier 4

Tier 4 - 323 tasks

Can start after Tier 3 completes

Implementation Notes

Model state as a sealed class or enum: ReceiptAttachmentState { idle, uploading(progress: double), uploaded(signedUrl: String), failed(message: String) }. Use StreamBuilder or a Riverpod StreamProvider / BLoC to consume ReceiptAttachmentService's state stream — do not poll. Wrap the entire widget in AnimatedSwitcher keyed by state type to get smooth cross-fade between states. For the progress percentage, display (progress * 100).round() to avoid float precision display artifacts.

The mandatory indicator should be driven by a parameter passed to the widget (isMandatory: bool) computed from ReceiptThresholdValidator in the parent — do not call the validator inside the widget to keep it pure. Signed URL caching: if the thumbnail URL changes on retry, bust the CachedNetworkImage cache using the URL as key. Ensure the remove action shows a brief loading state on the button itself before ReceiptAttachmentService responds.

Testing Requirements

Widget tests for all four states: verify idle state renders attach button; verify uploading state renders progress bar and percentage text; verify uploaded state renders thumbnail container and remove button; verify failed state renders retry button and error message text; verify mandatory indicator appears when ReceiptThresholdValidator returns true and state is idle; verify mandatory indicator absent when threshold not exceeded. Use mocktail to mock ReceiptAttachmentService stream and ReceiptThresholdValidator. Test AnimatedSwitcher transitions by pumping widget and asserting intermediate states. Golden tests for all four states.

Component
Receipt Camera Sheet
ui medium
Dependencies (4)
Implement the ReceiptThumbnailPreview Flutter widget that displays a compressed preview image of an attached receipt within the expense form. Show a loading shimmer while the image loads, an error fallback icon on failure, and a delete affordance button overlaid on the thumbnail. Apply VoiceOver/TalkBack semantics label 'Receipt attached, double-tap to remove'. epic-receipt-capture-and-attachment-core-logic-task-006 Create the ReceiptThresholdValidator service that evaluates whether an expense claim amount exceeds the 100 kr threshold requiring a receipt attachment. Implement isReceiptRequired(amount) and getThresholdConfig() methods reading from org-specific configuration. This validator drives the mandatory attachment UI state in the expense wizard. epic-receipt-capture-and-attachment-core-logic-task-004 Build the central ReceiptAttachmentService that orchestrates the end-to-end receipt capture and upload flow. Coordinate ReceiptImageCompressor → ReceiptStorageRepository → ClaimReceiptRepository in sequence. Expose attachReceiptToClaim(claimId, imageFile) returning a Stream<UploadProgress>, enabling non-blocking form submission while upload continues in background. epic-receipt-capture-and-attachment-core-logic-task-007 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. epic-receipt-capture-and-attachment-core-logic-task-008
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.