/// Editable list of parsed receipt lines before they are saved as a shop trip. library; import 'dart:io'; import 'package:flutter/material.dart'; import 'models/line_item.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'utils/supermarkets.dart'; import 'widgets/discounted_price.dart'; import 'widgets/item_edit_sheet.dart'; import 'widgets/receipt_highlight_overlay.dart'; import 'widgets/store_tag.dart'; import 'widgets/tag_editor.dart'; class ReviewScreen extends StatefulWidget { const ReviewScreen({ super.key, required this.items, required this.rawText, required this.repository, this.imagePath, this.supermarketNames = kSupermarkets, this.needsReview = false, this.detectedStore, this.ocrImageWidth, this.ocrImageHeight, }); final List items; final String rawText; final ProductRepository repository; final String? imagePath; final List supermarketNames; final bool needsReview; final String? detectedStore; final double? ocrImageWidth; final double? ocrImageHeight; @override State createState() => _ReviewScreenState(); } class _ReviewScreenState extends State { late List _items; late DateTime _shoppedAt; String? _supermarket; String? _customStore; bool _saving = false; final _tripName = TextEditingController(); List _tags = []; List _tagSuggestions = []; String? _highlightedItemId; bool _showTripExtras = false; @override void initState() { super.initState(); _items = List.from(widget.items); _shoppedAt = DateTime.now(); final detected = detectSupermarket( widget.rawText, names: widget.supermarketNames, ); if (detected != null) { _supermarket = detected; } else if (widget.detectedStore != null) { _supermarket = widget.detectedStore; } widget.repository .getUsedShopTags() .then((tags) { if (mounted) setState(() => _tagSuggestions = tags); }) .catchError((_) {}); } @override void dispose() { _tripName.dispose(); super.dispose(); } String? get _resolvedSupermarket { if (_supermarket == null) return null; if (_supermarket == kOtherSupermarket) { final custom = _customStore?.trim(); if (custom == null || custom.isEmpty) return null; return custom; } return _supermarket; } bool get _hasReceipt { final path = widget.imagePath; return path != null && path.isNotEmpty && File(path).existsSync(); } Future _edit({LineItem? existing}) async { final result = await showItemEditSheet(context, item: existing); if (result == null || !mounted) return; setState(() { if (existing == null) { _items.add(result); } else { final index = _items.indexWhere((item) => item.id == existing.id); if (index >= 0) { _items[index] = result.copyWith( sourceBounds: existing.sourceBounds, needsManualReview: false, ); } } _highlightedItemId = null; }); } void _toggleHighlight(LineItem item) { if (item.sourceBounds == null || !_hasReceipt) return; setState(() { _highlightedItemId = _highlightedItemId == item.id ? null : item.id; }); } Future _pickDate() async { final picked = await showDatePicker( context: context, initialDate: _shoppedAt, firstDate: DateTime(2018), lastDate: DateTime.now().add(const Duration(days: 1)), ); if (picked == null || !mounted) return; setState(() => _shoppedAt = picked); } Future _openReceiptPanel() async { final path = widget.imagePath; if (path == null || path.isEmpty) return; final file = File(path); if (!file.existsSync()) return; final highlighted = _highlightedItemId == null ? null : _items.cast().firstWhere( (item) => item?.id == _highlightedItemId, orElse: () => null, ); await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) { return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Receipt', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), if (highlighted?.sourceBounds != null) ReceiptHighlightOverlay( imagePath: path, bounds: highlighted!.sourceBounds!, maxHeight: 360, ) else GestureDetector( onTap: () async { await showDialog( context: context, builder: (ctx) => Dialog( insetPadding: const EdgeInsets.all(16), child: InteractiveViewer( child: Image.file(file, fit: BoxFit.contain), ), ), ); }, child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.file(file, fit: BoxFit.contain, height: 360), ), ), ], ), ), ); }, ); } Future _openOcrPanel() async { if (widget.rawText.isEmpty) return; await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) { return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Raw OCR text', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.55, ), child: SingleChildScrollView( child: SelectableText(widget.rawText), ), ), ], ), ), ); }, ); } Future _confirm() async { final supermarket = _resolvedSupermarket; if (supermarket == null) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('Select a supermarket.'))); return; } if (_items.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Add at least one product.')), ); return; } setState(() => _saving = true); try { await widget.repository.saveShop( supermarket: supermarket, shoppedAt: _shoppedAt, items: _items, receiptImagePath: widget.imagePath, name: _tripName.text, tags: _tags, ); if (!mounted) return; Navigator.of(context).pop(true); } catch (_) { if (!mounted) return; setState(() => _saving = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not save this shopping trip.')), ); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final total = _items.fold(0, (sum, item) => sum + item.price); final settings = SettingsScope.maybeOf(context); final stores = settings?.supermarkets ?? kDefaultSupermarkets; final names = [ ...{...widget.supermarketNames, ...stores.map((s) => s.name)}, ]; if (_supermarket != null && !names.contains(_supermarket)) { names.add(_supermarket!); } final flaggedCount = _items.where((item) => item.needsManualReview).length; final reviewMessage = flaggedCount > 0 ? 'Some lines could not be parsed confidently — ' '${flaggedCount == 1 ? '1 item is' : '$flaggedCount items are'} ' 'marked below. Check them against the receipt before saving.' : 'Item count or totals do not match the receipt. ' 'Check the items below before saving.'; return Scaffold( appBar: AppBar( title: Text( widget.rawText.isEmpty && widget.imagePath == null ? 'New trip' : 'Review items', ), ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.needsReview) ...[ Material( color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(12), child: Text( reviewMessage, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onErrorContainer, ), ), ), ), const SizedBox(height: 12), ], DropdownButtonFormField( key: ValueKey(_supermarket), initialValue: _supermarket, hint: const Text('Select supermarket'), decoration: const InputDecoration(labelText: 'Supermarket'), items: [ for (final store in names) DropdownMenuItem( value: store, child: StoreTag( name: store, stores: stores, compact: true, ), ), ], onChanged: _saving ? null : (value) => setState(() => _supermarket = value), ), if (_supermarket == kOtherSupermarket) ...[ const SizedBox(height: 12), TextField( enabled: !_saving, textCapitalization: TextCapitalization.words, decoration: const InputDecoration(labelText: 'Store name'), onChanged: (value) => _customStore = value, ), ], const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, leading: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon( _showTripExtras ? Icons.expand_less : Icons.edit_note_outlined, ), tooltip: _showTripExtras ? 'Hide trip name & tags' : 'Trip name & tags', onPressed: _saving ? null : () => setState( () => _showTripExtras = !_showTripExtras, ), ), const Icon(Icons.calendar_today_outlined), ], ), title: Text(formatDate(_shoppedAt)), subtitle: const Text('Shopping date'), onTap: _saving ? null : _pickDate, ), if (_showTripExtras) ...[ const SizedBox(height: 8), TextField( controller: _tripName, enabled: !_saving, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( labelText: 'Trip name', hintText: 'Optional, e.g. Weekly shop', ), ), const SizedBox(height: 12), TagEditor( tags: _tags, suggestions: _tagSuggestions, enabled: !_saving, onChanged: (tags) => setState(() => _tags = tags), ), const SizedBox(height: 8), ], ], ), ), Expanded( child: CustomScrollView( slivers: [ if (_items.isEmpty) SliverFillRemaining( hasScrollBody: false, child: Center( child: Text( 'No line items yet.\nAdd products for this trip.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ), ) else SliverPadding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverList.separated( itemCount: _items.length, separatorBuilder: (_, _) => const SizedBox(height: 4), itemBuilder: (context, index) { final item = _items[index]; final flagged = item.needsManualReview; final onReceipt = _highlightedItemId == item.id; return Dismissible( key: ValueKey(item.id), direction: DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), decoration: BoxDecoration( color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(12), ), child: Icon( Icons.delete_outline, color: theme.colorScheme.onErrorContainer, ), ), onDismissed: (_) { setState(() { _items.removeWhere((row) => row.id == item.id); }); }, child: ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: flagged ? BorderSide( color: theme.colorScheme.error .withValues(alpha: 0.55), ) : BorderSide.none, ), tileColor: flagged ? theme.colorScheme.errorContainer .withValues(alpha: 0.35) : onReceipt ? theme.colorScheme.primaryContainer .withValues(alpha: 0.35) : null, leading: item.sourceBounds != null && _hasReceipt ? IconButton( icon: Icon( onReceipt ? Icons.visibility : Icons.visibility_outlined, size: 20, ), tooltip: 'Show on receipt', onPressed: _saving ? null : () => _toggleHighlight(item), ) : flagged ? Icon( Icons.flag_outlined, color: theme.colorScheme.error, size: 20, ) : null, title: Text(item.name), subtitle: _itemSubtitle(item, flagged: flagged), trailing: DiscountedPrice(item: item), onTap: _saving ? null : () => _edit(existing: item), ), ); }, ), ), ], ), ), if (_hasReceipt || widget.rawText.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), child: Row( children: [ if (_hasReceipt) Expanded( child: OutlinedButton.icon( onPressed: _saving ? null : _openReceiptPanel, icon: const Icon(Icons.receipt_long_outlined, size: 18), label: const Text('Receipt'), ), ), if (_hasReceipt && widget.rawText.isNotEmpty) const SizedBox(width: 8), if (widget.rawText.isNotEmpty) Expanded( child: OutlinedButton.icon( onPressed: _saving ? null : _openOcrPanel, icon: const Icon(Icons.text_snippet_outlined, size: 18), label: const Text('OCR text'), ), ), ], ), ), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ OutlinedButton.icon( onPressed: _saving ? null : () => _edit(), icon: const Icon(Icons.add), label: const Text('Add item'), ), const SizedBox(height: 8), FilledButton( onPressed: _saving ? null : _confirm, child: Text( _saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}', ), ), ], ), ), ), ], ), ); } Widget? _itemSubtitle(LineItem item, {bool flagged = false}) { final parts = []; if (flagged) parts.add('Needs review'); if (item.quantity != null) { parts.add('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}'); } if (parts.isEmpty) return null; return Text(parts.join(' · ')); } }