/// Catalog of products from confirmed receipts. library; import 'package:flutter/material.dart'; import 'models/product.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 'widgets/product_image.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 _favouritesOnly = false; bool _searching = false; final TextEditingController _search = TextEditingController(); late Future> _future; @override void initState() { super.initState(); _future = _load(); _search.addListener(() => setState(() {})); } @override void dispose() { _search.dispose(); super.dispose(); } Future> _load() { return widget.repository.getAll( sort: _sort, favouritesOnly: _favouritesOnly, ); } void _reload() { setState(() => _future = _load()); } 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) _reload(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; 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: [ IconButton( tooltip: _searching ? 'Close search' : 'Search', onPressed: () { setState(() { _searching = !_searching; if (!_searching) _search.clear(); }); }, icon: Icon(_searching ? Icons.close : Icons.search), ), const SettingsButton(), ], ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), child: Row( children: [ Expanded( child: SegmentedButton( segments: const [ ButtonSegment( value: ProductSort.preferred, label: Text('Preferred'), ), ButtonSegment( value: ProductSort.name, label: Text('A–Z'), ), ], selected: {_sort}, onSelectionChanged: (selected) { setState(() { _sort = selected.first; _future = _load(); }); }, ), ), const SizedBox(width: 8), IconButton.filledTonal( tooltip: 'Favourites', isSelected: _favouritesOnly, onPressed: () { setState(() { _favouritesOnly = !_favouritesOnly; _future = _load(); }); }, icon: Icon( _favouritesOnly ? Icons.star : Icons.star_outline, ), ), ], ), ), Expanded( child: FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.hasError) { return Center( child: Text( 'Could not load products.', style: theme.textTheme.bodyLarge, ), ); } if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); } final products = _filter(snapshot.data!); if (snapshot.data!.isEmpty) { return Center( child: Text( _favouritesOnly ? '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]; return ListTile( dense: true, visualDensity: VisualDensity.compact, contentPadding: const EdgeInsets.symmetric( horizontal: 8, vertical: 0, ), leading: ProductImage( name: product.name, path: product.imagePath, size: 36, ), 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: Text( formatEuro(product.lastPrice), style: theme.textTheme.titleMedium, ), onTap: () => _openProduct(product), ); }, ); }, ), ), ], ), ); } }