/// Editable list of parsed receipt lines before they are saved as a shop trip. library; 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/item_edit_sheet.dart'; import 'widgets/store_tag.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, }); final List items; final String rawText; final ProductRepository repository; final String? imagePath; final List supermarketNames; final bool needsReview; final String? detectedStore; @override State createState() => _ReviewScreenState(); } class _ReviewScreenState extends State { late List _items; late DateTime _shoppedAt; String? _supermarket; String? _customStore; bool _saving = 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; } } 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; } 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; } } }); } 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 _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, ); 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!); } 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( 'Parsed totals do not match the receipt. Check the items before saving.', 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: const Icon(Icons.calendar_today_outlined), title: Text(formatDate(_shoppedAt)), subtitle: const Text('Shopping date'), onTap: _saving ? null : _pickDate, ), ], ), ), Expanded( child: _items.isEmpty ? Center( child: Text( 'No line items yet.\nAdd products for this trip.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ) : ListView.separated( padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), itemCount: _items.length, separatorBuilder: (_, _) => const SizedBox(height: 4), itemBuilder: (context, index) { final item = _items[index]; 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( title: Text(item.name), subtitle: _itemSubtitle(item), trailing: Text( formatEuro(item.price), style: theme.textTheme.titleMedium, ), onTap: _saving ? null : () => _edit(existing: item), ), ); }, ), ), if (widget.rawText.isNotEmpty) ExpansionTile( title: const Text('Raw OCR text'), children: [ ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: SelectableText(widget.rawText), ), ), ], ), 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) { final parts = [ if (item.quantity != null) '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}', ]; if (parts.isEmpty) return null; return Text(parts.join(' · ')); } }