/// Catalog of products from confirmed receipts. library; import 'package:flutter/material.dart'; import 'models/product.dart'; import 'models/supermarket.dart'; import 'product_detail_screen.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'utils/fuzzy.dart'; import 'utils/money.dart'; import 'utils/product_names.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/product_image.dart'; import 'widgets/product_name_conflicts.dart'; import 'widgets/profile_button.dart'; import 'widgets/settings_button.dart'; import 'widgets/store_tag.dart'; class ProductsScreen extends StatefulWidget { const ProductsScreen({super.key, required this.repository}); final ProductRepository repository; @override State createState() => _ProductsScreenState(); } class _ProductsScreenState extends State { ProductSort _sort = ProductSort.preferred; bool _searching = false; bool _editing = false; final TextEditingController _search = TextEditingController(); List? _products; ProductNameIssues _nameIssues = const ProductNameIssues( mergeGroups: [], spellingVariants: [], ); Object? _error; int _loadToken = 0; bool get _hasProducts => _products != null && _products!.isNotEmpty; @override void initState() { super.initState(); widget.repository.addListener(_load); _search.addListener(() => setState(() {})); _load(); } @override void dispose() { widget.repository.removeListener(_load); _search.dispose(); super.dispose(); } Future _load() async { final token = ++_loadToken; try { final products = await widget.repository.getAll(sort: _sort); final nameIssues = await widget.repository.getProductNameIssues(); if (!mounted || token != _loadToken) return; setState(() { _products = products; _nameIssues = nameIssues; _error = null; if (products.isEmpty) { _editing = false; if (_searching) { _searching = false; _search.clear(); } } }); } catch (error) { if (!mounted || token != _loadToken) return; setState(() => _error = error); } } List _filter(List products) { final query = _search.text.trim(); if (query.isEmpty) return products; final ranked = <({Product product, int score})>[]; for (final product in products) { final haystack = [ product.name, if (product.supermarket != null) product.supermarket!, if (product.category != null) product.category!, if (product.notes != null) product.notes!, ].join(' '); final score = fuzzyScore(query, haystack); if (score != null) { ranked.add((product: product, score: score)); } } ranked.sort((a, b) => b.score.compareTo(a.score)); return [for (final row in ranked) row.product]; } Future _openProduct(Product product) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProductDetailScreen( repository: widget.repository, productId: product.id, ), ), ); if (mounted) _load(); } Future _removeProduct(Product product) async { final confirmed = await confirmAction( context, title: 'Delete product?', message: '"${product.name}" will be removed from your catalog. Trip history keeps the purchase line.', ); if (!confirmed || !mounted) return; setState(() { _products = [ for (final item in _products ?? const []) if (item.id != product.id) item, ]; if (_products!.isEmpty) { _editing = false; _searching = false; _search.clear(); } }); try { await widget.repository.deleteProduct(product.id); } catch (_) { if (mounted) _load(); } } Future _removeAllProducts() async { final confirmed = await confirmAction( context, title: 'Delete all products?', message: 'This removes every product from your catalog. Shopping trips stay in the shop log.', ); if (!confirmed || !mounted) return; setState(() { _products = const []; _editing = false; _searching = false; _search.clear(); }); try { await widget.repository.deleteAllProducts(); } catch (_) { if (mounted) _load(); } } Set get _issueProductIds => _nameIssues.productIds; Future _resolveNameIssues({int? focusProductId}) async { final issues = await widget.repository.getProductNameIssues(); if (!mounted || issues.isEmpty) return; final resolved = await showProductNameConflictSheet( context, repository: widget.repository, issues: issues, focusProductId: focusProductId, ); if (resolved && mounted) _load(); } Future _toggleFavourite(Product product) async { final next = !product.favourite; setState(() { _products = [ for (final item in _products ?? const []) if (item.id == product.id) item.copyWith(favourite: next) else item, ]; if (_sort == ProductSort.favourite && !next) { _products = [ for (final item in _products!) if (item.id != product.id) item, ]; if (_products!.isEmpty) _editing = false; } }); try { await widget.repository.setFavourite(product.id, next); } catch (_) { if (mounted) _load(); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; final products = _products == null ? const [] : _filter(_products!); return Scaffold( appBar: AppBar( title: _searching ? TextField( controller: _search, autofocus: true, decoration: const InputDecoration( hintText: 'Search products', border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, filled: false, ), ) : const Text('Products'), actions: [ if (!_editing) IconButton( tooltip: _searching ? 'Close search' : 'Search', onPressed: _hasProducts ? () { setState(() { _searching = !_searching; if (!_searching) _search.clear(); }); } : null, icon: Icon(_searching ? Icons.close : Icons.search), ), if (_hasProducts && _nameIssues.isNotEmpty && !_editing) IconButton( tooltip: 'Resolve name issues', onPressed: _resolveNameIssues, icon: const Icon(kProductNameConflictIcon), ), if (_hasProducts) IconButton( tooltip: _editing ? 'Done' : 'Edit', onPressed: () => setState(() { _editing = !_editing; if (_editing) { _searching = false; _search.clear(); } }), icon: Icon(_editing ? Icons.check : Icons.edit_outlined), ), if (_editing) IconButton( tooltip: 'Delete all products', onPressed: _removeAllProducts, icon: const Icon(Icons.delete_outline), ), if (!_editing) ...const [ProfileButton(), SettingsButton()], ], ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), child: SizedBox( width: double.infinity, child: SegmentedButton( expandedInsets: EdgeInsets.zero, showSelectedIcon: false, segments: const [ ButtonSegment( value: ProductSort.name, label: Text('A–Z'), ), ButtonSegment( value: ProductSort.preferred, label: Text('Frequent'), ), ButtonSegment( value: ProductSort.favourite, icon: Icon(Icons.star), label: Text('Star'), ), ], selected: {_sort}, onSelectionChanged: (selected) { setState(() { _sort = selected.first; }); _load(); }, ), ), ), if (_nameIssues.isNotEmpty && !_editing) ProductNameConflictBanner( issues: _nameIssues, onTap: _resolveNameIssues, ), Expanded(child: _buildList(theme, stores, products)), ], ), ); } Widget _buildList( ThemeData theme, List stores, List products, ) { if (_error != null && _products == null) { return Center( child: Text( 'Could not load products.', style: theme.textTheme.bodyLarge, ), ); } if (_products == null) { return const Center(child: CircularProgressIndicator()); } if (_products!.isEmpty) { return Center( child: Text( _sort == ProductSort.favourite ? 'Star products to keep them as favourites.' : 'Confirmed receipt items will show up here.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ); } if (products.isEmpty) { return Center( child: Text( 'No products match this search.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ); } return ListView.separated( padding: const EdgeInsets.fromLTRB(8, 0, 8, 16), itemCount: products.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final product = products[index]; final hasIssue = _issueProductIds.contains(product.id); return ListTile( dense: true, visualDensity: VisualDensity.compact, contentPadding: const EdgeInsets.symmetric( horizontal: 8, vertical: 0, ), minLeadingWidth: _editing ? 72 : 36, leading: _editing ? Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Delete', visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 36, minHeight: 36, ), onPressed: () => _removeProduct(product), icon: const Icon(Icons.delete_outline), ), IconButton( tooltip: product.favourite ? 'Remove favourite' : 'Favourite', visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 36, minHeight: 36, ), onPressed: () => _toggleFavourite(product), icon: Icon( product.favourite ? Icons.star : Icons.star_outline, ), ), ], ) : Stack( clipBehavior: Clip.none, children: [ ProductImage( name: product.name, path: product.imagePath, size: 36, ), if (hasIssue && !_editing) Positioned( right: -2, top: -2, child: ProductNameConflictMarker( compact: true, onTap: () => _resolveNameIssues(focusProductId: product.id), ), ), ], ), title: Text( product.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: product.supermarket == null ? null : Padding( padding: const EdgeInsets.only(top: 2), child: Align( alignment: Alignment.centerLeft, child: StoreTag( name: product.supermarket, stores: stores, compact: true, ), ), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (hasIssue && !_editing) Padding( padding: const EdgeInsets.only(right: 4), child: ProductNameConflictMarker( onTap: () => _resolveNameIssues(focusProductId: product.id), ), ), Text( formatEuro(product.lastPrice), style: theme.textTheme.titleMedium, ), ], ), onTap: _editing ? null : () => _openProduct(product), ); }, ); } }