/// Product: photo, name, favourite, price history chart, and notes. library; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'models/product.dart'; import 'models/purchase.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'shop_detail_screen.dart'; import 'utils/categories.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'utils/supermarkets.dart'; import 'widgets/price_chart.dart'; import 'widgets/product_image.dart'; import 'widgets/store_tag.dart'; class ProductDetailScreen extends StatefulWidget { const ProductDetailScreen({ super.key, required this.repository, required this.productId, }); final ProductRepository repository; final int productId; @override State createState() => _ProductDetailScreenState(); } class _ProductDetailScreenState extends State { late Future<({Product product, List purchases})> _future; late final TextEditingController _nameController; late final TextEditingController _notesController; final ImagePicker _picker = ImagePicker(); bool _ready = false; @override void initState() { super.initState(); _nameController = TextEditingController(); _notesController = TextEditingController(); _future = _load(); } @override void dispose() { _saveNotes(); _nameController.dispose(); _notesController.dispose(); super.dispose(); } Future<({Product product, List purchases})> _load() async { final product = await widget.repository.getProduct(widget.productId); if (product == null) { throw StateError('Product not found'); } final purchases = await widget.repository.getPurchases(widget.productId); if (!_ready) { _nameController.text = product.name; _notesController.text = product.notes ?? ''; _ready = true; } return (product: product, purchases: purchases); } void _reload() { setState(() => _future = _load()); } Future _saveName() async { final name = _nameController.text.trim(); if (name.isEmpty) return; await widget.repository.updateProduct(id: widget.productId, name: name); if (mounted) _reload(); } Future _saveNotes() async { await widget.repository.updateProduct( id: widget.productId, notes: _notesController.text.trim(), ); } Future _toggleFavourite(Product product) async { await widget.repository.setFavourite(product.id, !product.favourite); if (mounted) _reload(); } Future _setSupermarket(String? value) async { if (value == null) return; await widget.repository.updateProduct( id: widget.productId, supermarket: value, ); if (mounted) _reload(); } Future _pickImage(ImageSource source) async { final file = await _picker.pickImage(source: source, imageQuality: 85); if (file == null) return; await widget.repository.setProductImage(widget.productId, file.path); if (mounted) _reload(); } Future _chooseImageSource() async { final source = await showModalBottomSheet( context: context, showDragHandle: true, builder: (context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.photo_camera_outlined), title: const Text('Take photo'), onTap: () => Navigator.pop(context, ImageSource.camera), ), ListTile( leading: const Icon(Icons.photo_library_outlined), title: const Text('Pick from gallery'), onTap: () => Navigator.pop(context, ImageSource.gallery), ), ], ), ); }, ); if (source == null) return; await _pickImage(source); } Future _pickStore() async { final settings = SettingsScope.maybeOf(context); final stores = settings?.supermarkets ?? kDefaultSupermarkets; final chosen = await showModalBottomSheet( context: context, showDragHandle: true, builder: (context) { return SafeArea( child: ListView( shrinkWrap: true, children: [ for (final store in stores) ListTile( leading: StoreTag(name: store.name, stores: stores), title: Text(store.name), onTap: () => Navigator.pop(context, store.name), ), ], ), ); }, ); if (chosen != null) await _setSupermarket(chosen); } Future _setCategory(String? value, {bool clear = false}) async { await widget.repository.updateProduct( id: widget.productId, category: clear ? null : value, clearCategory: clear, ); if (mounted) _reload(); } Future _pickCategory(Product product) async { final used = await widget.repository.getUsedCategories(); if (!mounted) return; final categories = mergeCategories([ ...used, if (product.category != null) product.category!, ]); final chosen = await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, builder: (context) { return SafeArea( child: ListView( shrinkWrap: true, children: [ ListTile( leading: const Icon(Icons.clear), title: const Text('No category'), onTap: () => Navigator.pop(context, ''), ), for (final name in categories) ListTile( leading: Icon( name == product.category ? Icons.check_circle : Icons.category_outlined, ), title: Text(name), onTap: () => Navigator.pop(context, name), ), ListTile( leading: const Icon(Icons.add), title: const Text('Custom category'), onTap: () => Navigator.pop(context, '__custom__'), ), ], ), ); }, ); if (!mounted || chosen == null) return; if (chosen.isEmpty) { await _setCategory(null, clear: true); return; } if (chosen == '__custom__') { final custom = await _askCustomCategory(product.category); if (custom == null || !mounted) return; if (custom.isEmpty) { await _setCategory(null, clear: true); } else { await _setCategory(custom); } return; } await _setCategory(chosen); } Future _askCustomCategory(String? current) async { return showDialog( context: context, builder: (context) => _CategoryNameDialog(initial: current), ); } Future _openShop(int shopId) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ShopDetailScreen( repository: widget.repository, shopId: shopId, ), ), ); if (mounted) _reload(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final stores = SettingsScope.maybeOf(context)?.supermarkets ?? kDefaultSupermarkets; return Scaffold( appBar: AppBar(title: const Text('Product')), body: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.hasError) { return Center( child: Text( 'Could not load this product.', style: theme.textTheme.bodyLarge, ), ); } if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); } final product = snapshot.data!.product; final purchases = snapshot.data!.purchases; return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: _chooseImageSource, child: Stack( children: [ ProductImage( name: product.name, path: product.imagePath, size: 88, ), Positioned( right: 0, bottom: 0, child: Material( color: theme.colorScheme.primary, shape: const CircleBorder(), child: Padding( padding: const EdgeInsets.all(4), child: Icon( Icons.camera_alt, size: 14, color: theme.colorScheme.onPrimary, ), ), ), ), ], ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: TextField( controller: _nameController, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( labelText: 'Name', isDense: true, ), onSubmitted: (_) => _saveName(), onEditingComplete: _saveName, ), ), IconButton( tooltip: product.favourite ? 'Remove favourite' : 'Add favourite', onPressed: () => _toggleFavourite(product), icon: Icon( product.favourite ? Icons.star : Icons.star_outline, color: product.favourite ? theme.colorScheme.primary : null, ), ), ], ), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: InkWell( onTap: _pickStore, borderRadius: BorderRadius.circular(8), child: product.supermarket == null ? Text( 'Add supermarket', style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.primary, ), ) : StoreTag( name: product.supermarket, stores: stores, ), ), ), ], ), ), ], ), const SizedBox(height: 24), Text( formatEuro(product.lastPrice), style: theme.textTheme.headlineMedium, ), Text('Price per unit', style: theme.textTheme.bodyMedium), const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: ActionChip( avatar: const Icon(Icons.category_outlined, size: 18), label: Text(product.category ?? 'Add category'), onPressed: () => _pickCategory(product), ), ), const SizedBox(height: 24), Text( 'Price and purchase history', style: theme.textTheme.titleMedium, ), const SizedBox(height: 8), if (purchases.isEmpty) Text( 'Scan a receipt to start a history.', style: theme.textTheme.bodyMedium, ) else ...[ PriceChart(purchases: purchases), const SizedBox(height: 8), ...purchases.map((purchase) { return ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: const Icon(Icons.shopping_bag_outlined), title: Text(formatEuro(purchase.unitOrLinePrice)), subtitle: Text( [ formatDate(purchase.boughtAt), purchase.supermarket, if (purchase.quantity != null) '${purchase.quantity}×', ].join(' · '), ), trailing: const Icon(Icons.chevron_right), onTap: () => _openShop(purchase.shopId), ); }), ], const SizedBox(height: 24), Text('Notes', style: theme.textTheme.titleMedium), const SizedBox(height: 8), TextField( controller: _notesController, minLines: 3, maxLines: 6, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( hintText: 'Add a note about this product', ), onEditingComplete: _saveNotes, onTapOutside: (_) => _saveNotes(), ), ], ); }, ), ); } } class _CategoryNameDialog extends StatefulWidget { const _CategoryNameDialog({this.initial}); final String? initial; @override State<_CategoryNameDialog> createState() => _CategoryNameDialogState(); } class _CategoryNameDialogState extends State<_CategoryNameDialog> { late final TextEditingController _controller; @override void initState() { super.initState(); _controller = TextEditingController(text: widget.initial ?? ''); } @override void dispose() { _controller.dispose(); super.dispose(); } void _save() => Navigator.pop(context, _controller.text.trim()); @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Category'), content: TextField( controller: _controller, textCapitalization: TextCapitalization.sentences, autofocus: true, decoration: const InputDecoration(labelText: 'Category'), onSubmitted: (_) => _save(), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: _save, child: const Text('Save'), ), ], ); } }