/// 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 'utils/dates.dart'; import 'utils/money.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/item_edit_sheet.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 { late Future<({Shop shop, List items})> _future; bool _editing = false; @override void initState() { super.initState(); _future = _load(); } Future<({Shop shop, List items})> _load() async { final shop = await widget.repository.getShop(widget.shopId); if (shop == null) { throw StateError('Shop not found'); } final items = await widget.repository.getShopItems(widget.shopId); return (shop: shop, items: items); } void _reload() { setState(() => _future = _load()); } 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 _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, ); if (mounted) _reload(); } 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; await widget.repository.deleteShopItem(item.id); if (mounted) _reload(); } 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), ), ], ), body: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.hasError) { return Center( child: Text( 'Could not load this trip.', style: theme.textTheme.bodyLarge, ), ); } if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); } final shop = snapshot.data!.shop; final items = snapshot.data!.items; 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, 32), children: [ Text(shop.supermarket, style: theme.textTheme.headlineMedium), const SizedBox(height: 4), Text( formatDate(shop.shoppedAt), style: theme.textTheme.bodyLarge, ), 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 ? 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, ), ), ], ); }, ), ); } }