/// Editable list of parsed receipt lines before they are saved as products. library; import 'package:flutter/material.dart'; import 'models/line_item.dart'; import 'products_screen.dart'; import 'services/product_repository.dart'; import 'utils/money.dart'; class ReviewScreen extends StatefulWidget { const ReviewScreen({ super.key, required this.items, required this.rawText, required this.repository, }); final List items; final String rawText; final ProductRepository repository; @override State createState() => _ReviewScreenState(); } class _ReviewScreenState extends State { late List _items; bool _saving = false; @override void initState() { super.initState(); _items = List.from(widget.items); } Future _edit({LineItem? existing}) async { final result = await showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) => _ItemEditSheet(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 _confirm() async { setState(() => _saving = true); try { await widget.repository.upsertItems(_items); if (!mounted) return; Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (_) => ProductsScreen( repository: widget.repository, savedCount: _items.length, ), ), (route) => route.isFirst, ); } catch (_) { if (!mounted) return; setState(() => _saving = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not save products.')), ); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: const Text('Review items')), body: Column( children: [ Expanded( child: _items.isEmpty ? Center( child: Text( 'No line items found.\nAdd anything the parser missed.', 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: item.quantity == null ? null : Text( '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', ), 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…' : 'Confirm', ), ), ], ), ), ), ], ), ); } } class _ItemEditSheet extends StatefulWidget { const _ItemEditSheet({this.item}); final LineItem? item; @override State<_ItemEditSheet> createState() => _ItemEditSheetState(); } class _ItemEditSheetState extends State<_ItemEditSheet> { late final TextEditingController _nameController; late final TextEditingController _priceController; String? _error; @override void initState() { super.initState(); final item = widget.item; _nameController = TextEditingController(text: item?.name ?? ''); _priceController = TextEditingController( text: item == null ? '' : item.price.toStringAsFixed(2), ); } @override void dispose() { _nameController.dispose(); _priceController.dispose(); super.dispose(); } void _save() { final name = _nameController.text.trim(); final price = tryParsePrice(_priceController.text); if (name.isEmpty || price == null) { setState(() => _error = 'Enter a product name and a price.'); return; } Navigator.of(context).pop( LineItem( id: widget.item?.id ?? 'manual-${DateTime.now().microsecondsSinceEpoch}', name: name, price: roundMoney(price), quantity: widget.item?.quantity, unitPrice: widget.item?.unitPrice, ), ); } @override Widget build(BuildContext context) { final bottom = MediaQuery.viewInsetsOf(context).bottom; return Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( widget.item == null ? 'Add item' : 'Edit item', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), TextField( controller: _nameController, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration(labelText: 'Product name'), autofocus: widget.item == null, ), const SizedBox(height: 12), TextField( controller: _priceController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration(labelText: 'Price'), ), if (_error != null) ...[ const SizedBox(height: 8), Text( _error!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ], const SizedBox(height: 16), FilledButton( onPressed: _save, child: const Text('Save'), ), ], ), ); } }