/// One shopping trip: receipt photo, supermarket, products and costs. library; import 'dart:io'; import 'package:flutter/material.dart'; import 'models/line_item.dart'; import 'models/shop.dart'; import 'product_detail_screen.dart'; import 'services/product_repository.dart'; import 'services/receipt_capture.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/item_edit_sheet.dart'; import 'widgets/name_dialog.dart'; import 'widgets/tag_editor.dart'; class ShopDetailScreen extends StatefulWidget { const ShopDetailScreen({ super.key, required this.repository, required this.shopId, }); final ProductRepository repository; final int shopId; @override State createState() => _ShopDetailScreenState(); } class _ShopDetailScreenState extends State { Shop? _shop; List? _items; Object? _error; bool _editing = false; List _tagSuggestions = []; int _loadToken = 0; bool _addingReceipt = false; @override void initState() { super.initState(); widget.repository.addListener(_load); _load(); widget.repository .getUsedShopTags() .then((tags) { if (mounted) setState(() => _tagSuggestions = tags); }) .catchError((_) {}); } @override void dispose() { widget.repository.removeListener(_load); super.dispose(); } Future _load() async { final token = ++_loadToken; try { final shop = await widget.repository.getShop(widget.shopId); if (shop == null) throw StateError('Shop not found'); final items = await widget.repository.getShopItems(widget.shopId); if (!mounted || token != _loadToken) return; setState(() { _shop = shop; _items = items; _error = null; }); } catch (error) { if (!mounted || token != _loadToken) return; setState(() => _error = error); } } Future _openReceipt(File file) async { await showDialog( context: context, builder: (context) { return Dialog( insetPadding: const EdgeInsets.all(16), child: InteractiveViewer( child: Image.file(file, fit: BoxFit.contain), ), ); }, ); } Future _openProduct(int productId) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProductDetailScreen( repository: widget.repository, productId: productId, ), ), ); } Future _addItem() async { final shop = _shop; if (shop == null) return; final result = await showItemEditSheet(context); if (result == null || !mounted) return; final pendingId = -DateTime.now().microsecondsSinceEpoch; final pending = ShopItem( id: pendingId, shopId: shop.id, name: result.name, price: result.price, quantity: result.quantity, unitPrice: result.unitPrice, ); setState(() { _items = [...?_items, pending]; _shop = Shop( id: shop.id, supermarket: shop.supermarket, shoppedAt: shop.shoppedAt, total: roundMoney(shop.total + result.price), itemCount: shop.itemCount + 1, name: shop.name, receiptImagePath: shop.receiptImagePath, tags: shop.tags, ); }); try { final saved = await widget.repository.addShopItem( shopId: shop.id, item: result, ); if (!mounted) return; setState(() { _items = [ for (final entry in _items ?? const []) if (entry.id == pendingId) saved else entry, ]; }); } catch (_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not add this product.')), ); _load(); } } Future _addFromReceipt() async { final shop = _shop; if (shop == null || _addingReceipt) return; setState(() => _addingReceipt = true); try { final captured = await captureAndParseReceipt(context); if (captured == null || !mounted) return; var added = 0; for (final item in captured.items) { await widget.repository.addShopItem(shopId: shop.id, item: item); added++; } if (!mounted) return; await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( added == 1 ? 'Added 1 item from receipt.' : 'Added $added items from receipt.', ), ), ); } catch (_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not add items from this receipt.')), ); _load(); } finally { if (mounted) setState(() => _addingReceipt = false); } } Future _editItem(ShopItem item) async { final result = await showItemEditSheet( context, item: LineItem( id: '${item.id}', name: item.name, price: item.price, quantity: item.quantity, unitPrice: item.unitPrice, ), ); if (result == null || !mounted) return; await widget.repository.updateShopItem( id: item.id, name: result.name, price: result.price, quantity: result.quantity, unitPrice: result.unitPrice, clearQuantity: result.quantity == null, ); if (mounted) _load(); } Future _removeItem(ShopItem item) async { final confirmed = await confirmAction( context, title: 'Remove item?', message: '"${item.name}" will be removed from this trip. The product stays in your catalog.', confirmLabel: 'Remove', ); if (!confirmed || !mounted) return; final isLastItem = (_items?.length ?? 0) == 1; final shop = _shop; setState(() { _items = [ for (final entry in _items ?? const []) if (entry.id != item.id) entry, ]; if (shop != null) { _shop = Shop( id: shop.id, supermarket: shop.supermarket, shoppedAt: shop.shoppedAt, total: roundMoney(shop.total - item.price), itemCount: shop.itemCount - 1, name: shop.name, receiptImagePath: shop.receiptImagePath, tags: shop.tags, ); } }); try { await widget.repository.deleteShopItem(item.id); if (!mounted || !isLastItem || shop == null) return; await _confirmRemoveEmptyTrip(shop); } catch (_) { if (mounted) _load(); } } Future _confirmRemoveEmptyTrip(Shop shop) async { final removeTrip = await confirmAction( context, title: 'Remove trip?', message: 'This trip has no products left. Remove it from your shop log? ' 'Products from this trip stay in your catalog.', confirmLabel: 'Remove trip', ); if (!mounted) return; if (!removeTrip) return; try { await widget.repository.deleteShop(shop.id); if (mounted) Navigator.of(context).pop(); } catch (_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not remove this trip.')), ); _load(); } } Future _editName(Shop shop) async { final name = await showNameDialog( context, title: 'Trip name', label: 'Name', initial: shop.name, allowEmpty: true, ); if (name == null || !mounted) return; await widget.repository.updateShop( id: shop.id, name: name, clearName: name.isEmpty, ); if (mounted) _load(); } Future _editDate(Shop shop) async { final picked = await showDatePicker( context: context, initialDate: shop.shoppedAt, firstDate: DateTime(2018), lastDate: DateTime.now().add(const Duration(days: 1)), ); if (picked == null || !mounted) return; await widget.repository.updateShop( id: shop.id, shoppedAt: DateTime(picked.year, picked.month, picked.day), ); if (mounted) _load(); } Future _setTags(Shop shop, List tags) async { await widget.repository.updateShop(id: shop.id, tags: tags); if (mounted) _load(); } Widget? _itemSubtitle(ShopItem item) { final parts = [ if (item.quantity != null) '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', if (_editing) formatEuro(item.price), ]; if (parts.isEmpty) return null; return Text(parts.join(' · ')); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: const Text('Shopping trip'), actions: [ IconButton( tooltip: _editing ? 'Done' : 'Edit', onPressed: () => setState(() => _editing = !_editing), icon: Icon(_editing ? Icons.check : Icons.edit_outlined), ), ], ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: _editing ? Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Expanded( child: FilledButton.tonalIcon( onPressed: _addingReceipt ? null : _addFromReceipt, icon: _addingReceipt ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.receipt_long_outlined), label: const Text('Add receipt'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), ), ), ), const SizedBox(width: 12), Expanded( child: FilledButton.icon( onPressed: _addingReceipt ? null : _addItem, icon: const Icon(Icons.add), label: const Text('Add product'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), ), ), ), ], ), ) : null, body: Builder( builder: (context) { if (_error != null) { return Center( child: Text( 'Could not load this trip.', style: theme.textTheme.bodyLarge, ), ); } final shop = _shop; final items = _items; if (shop == null || items == null) { return const Center(child: CircularProgressIndicator()); } final receipt = shop.receiptImagePath; final receiptFile = receipt == null ? null : File(receipt); final hasReceipt = receiptFile != null && receiptFile.existsSync(); return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), children: [ if (_editing) ListTile( contentPadding: EdgeInsets.zero, title: Text( shop.name?.isNotEmpty == true ? shop.name! : 'Add a trip name', ), subtitle: const Text('Optional name for this shop log'), trailing: const Icon(Icons.edit_outlined), onTap: () => _editName(shop), ) else Text(shop.displayName, style: theme.textTheme.headlineMedium), if (_editing || (shop.name != null && shop.name!.isNotEmpty)) ...[ const SizedBox(height: 4), Text(shop.supermarket, style: theme.textTheme.titleMedium), ], const SizedBox(height: 4), if (_editing) ListTile( contentPadding: EdgeInsets.zero, title: Text(formatDate(shop.shoppedAt)), subtitle: const Text('Trip date'), trailing: const Icon(Icons.calendar_today_outlined), onTap: () => _editDate(shop), ) else Text( formatDate(shop.shoppedAt), style: theme.textTheme.bodyLarge, ), if (_editing) ...[ const SizedBox(height: 16), TagEditor( tags: shop.tags, suggestions: _tagSuggestions, onChanged: (tags) => _setTags(shop, tags), ), ] else if (shop.tags.isNotEmpty) ...[ const SizedBox(height: 12), TagChips(tags: shop.tags), ], if (hasReceipt) ...[ const SizedBox(height: 16), GestureDetector( onTap: () => _openReceipt(receiptFile), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.file( receiptFile, height: 180, width: double.infinity, fit: BoxFit.cover, cacheWidth: 900, ), ), ), ], const SizedBox(height: 24), Text('Products', style: theme.textTheme.titleMedium), const SizedBox(height: 8), if (items.isEmpty) Text( 'No items on this trip.', style: theme.textTheme.bodyLarge, ), for (final item in items) ListTile( contentPadding: EdgeInsets.zero, title: Text(item.name), subtitle: _itemSubtitle(item), trailing: _editing ? Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Edit', visualDensity: VisualDensity.compact, onPressed: () => _editItem(item), icon: const Icon(Icons.edit_outlined), ), IconButton( tooltip: 'Remove', visualDensity: VisualDensity.compact, onPressed: () => _removeItem(item), icon: const Icon(Icons.delete_outline), ), ], ) : Text( formatEuro(item.price), style: theme.textTheme.titleMedium, ), onTap: _editing || item.productId == null ? null : () => _openProduct(item.productId!), ), const Divider(), ListTile( contentPadding: EdgeInsets.zero, title: Text('Total', style: theme.textTheme.titleMedium), trailing: Text( formatEuro(shop.total), style: theme.textTheme.titleLarge, ), ), ], ); }, ), ); } }