/// Log of saved shopping trips, with a monthly heatmap and product search. library; import 'package:flutter/material.dart'; import 'models/shop.dart'; import 'models/supermarket.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'shop_detail_screen.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'utils/shop_calendar.dart'; import 'utils/shop_log_filter.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/settings_button.dart'; import 'widgets/shop_heatmap.dart'; import 'widgets/store_tag.dart'; import 'widgets/tag_editor.dart'; class ShopLogScreen extends StatefulWidget { const ShopLogScreen({super.key, required this.repository}); final ProductRepository repository; @override State createState() => _ShopLogScreenState(); } class _ShopLogScreenState extends State { late Future<({List shops, Map> productNames})> _future; final _search = TextEditingController(); bool _editing = false; bool _searching = false; DateTime _month = monthStart(DateTime.now()); DateTime? _selectedDay; String? _tagFilter; @override void initState() { super.initState(); _future = _load(); _search.addListener(() => setState(() {})); } @override void dispose() { _search.dispose(); super.dispose(); } Future<({List shops, Map> productNames})> _load() async { final shops = await widget.repository.getShops(); final productNames = await widget.repository.getShopItemNamesByShop(); return (shops: shops, productNames: productNames); } void _reload() { setState(() => _future = _load()); } Future _openShop(Shop shop) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ShopDetailScreen(repository: widget.repository, shopId: shop.id), ), ); if (mounted) _reload(); } Future _removeShop(Shop shop) async { final confirmed = await confirmAction( context, title: 'Remove shopping trip?', message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.', ); if (!confirmed || !mounted) return; await widget.repository.deleteShop(shop.id); if (mounted) _reload(); } void _selectDay(DateTime? day) { setState(() => _selectedDay = day); } void _goMonth(int delta) { final next = addMonths(_month, delta); final current = monthStart(DateTime.now()); if (delta > 0 && !next.isBefore(current) && next != current) return; setState(() { _month = next.isAfter(current) ? current : next; _selectedDay = null; }); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; final nowMonth = monthStart(DateTime.now()); return Scaffold( appBar: AppBar( title: _searching ? TextField( controller: _search, autofocus: true, decoration: const InputDecoration( hintText: 'Search products in trips', border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, filled: false, ), ) : const Text('Shop log'), actions: [ IconButton( tooltip: _searching ? 'Close search' : 'Search products', onPressed: () { setState(() { _searching = !_searching; if (!_searching) _search.clear(); }); }, icon: Icon(_searching ? Icons.close : Icons.search), ), IconButton( tooltip: _editing ? 'Done' : 'Edit', onPressed: () => setState(() => _editing = !_editing), icon: Icon(_editing ? Icons.check : Icons.edit_outlined), ), const SettingsButton(), ], ), body: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.hasError) { return Center( child: Text( 'Could not load the shop log.', style: theme.textTheme.bodyLarge, ), ); } if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); } final shops = snapshot.data!.shops; final productNames = snapshot.data!.productNames; final counts = tripCountsByDay([ for (final shop in shops) if (isSameMonth(shop.shoppedAt, _month)) shop.shoppedAt, ]); final monthShops = [ for (final shop in shops) if (isSameMonth(shop.shoppedAt, _month)) shop, ]; final monthTotal = monthShops.fold( 0, (sum, shop) => sum + shop.total, ); final visible = filterShops( shops: shops, productNames: productNames, selectedDay: _selectedDay, tag: _tagFilter, query: _search.text, ); return ListView( padding: const EdgeInsets.only(bottom: 16), children: [ ShopHeatmap( month: _month, counts: counts, monthTotal: monthTotal, monthTripCount: monthShops.length, canGoForward: _month.isBefore(nowMonth), selectedDay: _selectedDay, onPreviousMonth: () => _goMonth(-1), onNextMonth: () => _goMonth(1), onSelectDay: _selectDay, ), if (_selectedDay != null || _tagFilter != null) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Wrap( spacing: 8, runSpacing: 8, children: [ if (_selectedDay != null) InputChip( label: Text(formatDate(_selectedDay!)), onDeleted: () => _selectDay(null), ), if (_tagFilter != null) InputChip( label: Text(_tagFilter!), onDeleted: () => setState(() => _tagFilter = null), ), ], ), ), if (shops.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( 'Scan a receipt or enter a trip to log it.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ) else if (visible.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text( _search.text.trim().isNotEmpty ? 'No trips contain that product.' : 'No trips match this filter.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ) else for (final shop in visible) Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), child: _ShopLogTile( shop: shop, stores: stores, editing: _editing, selectedTag: _tagFilter, onOpen: () => _openShop(shop), onRemove: () => _removeShop(shop), onTagTap: (tag) { setState(() { _tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase() ? null : tag; }); }, ), ), ], ); }, ), ); } } class _ShopLogTile extends StatelessWidget { const _ShopLogTile({ required this.shop, required this.stores, required this.editing, required this.onOpen, required this.onRemove, required this.onTagTap, this.selectedTag, }); final Shop shop; final List stores; final bool editing; final VoidCallback onOpen; final VoidCallback onRemove; final ValueChanged onTagTap; final String? selectedTag; @override Widget build(BuildContext context) { final theme = Theme.of(context); final count = shop.itemCount; final hasName = shop.name != null && shop.name!.isNotEmpty; final meta = [ formatDate(shop.shoppedAt), count == 1 ? '1 product' : '$count products', ].join(' ยท '); return Material( color: Colors.transparent, child: InkWell( onTap: editing ? null : onOpen, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (hasName) Text(shop.name!, style: theme.textTheme.titleMedium) else StoreTag(name: shop.supermarket, stores: stores), const SizedBox(height: 4), if (hasName) ...[ StoreTag( name: shop.supermarket, stores: stores, compact: true, ), const SizedBox(height: 4), ], Text(meta, style: theme.textTheme.bodyMedium), if (shop.tags.isNotEmpty) ...[ const SizedBox(height: 8), TagChips( tags: shop.tags, compact: true, selected: selectedTag, onTap: onTagTap, ), ], ], ), ), const SizedBox(width: 8), editing ? IconButton( tooltip: 'Remove', onPressed: onRemove, icon: const Icon(Icons.delete_outline), ) : Padding( padding: const EdgeInsets.only(top: 8, right: 8), child: Text( formatEuro(shop.total), style: theme.textTheme.titleMedium, ), ), ], ), ), ), ); } }