/// Dashboard of catalog, trips, lists, and this month's spend. library; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'models/app_overview.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 'widgets/profile_button.dart'; import 'widgets/settings_button.dart'; import 'widgets/store_tag.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, required this.repository, required this.onOpenProducts, required this.onOpenLists, required this.onOpenShopLog, required this.onOpenAdd, }); final ProductRepository repository; final VoidCallback onOpenProducts; final VoidCallback onOpenLists; final VoidCallback onOpenShopLog; final VoidCallback onOpenAdd; @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { AppOverview? _overview; List _stores = const []; Object? _error; int _loadToken = 0; final ImagePicker _picker = ImagePicker(); @override void initState() { super.initState(); widget.repository.addListener(_load); _load(); } @override void dispose() { widget.repository.removeListener(_load); super.dispose(); } Future _load() async { final token = ++_loadToken; try { final overview = await widget.repository.getOverview(); final stores = await widget.repository.getSupermarkets(); final lastTripStores = await widget.repository.getLastTripSupermarkets(); if (!mounted || token != _loadToken) return; setState(() { _overview = overview; _stores = sortStoresByLastTrip(stores, lastTripStores); _error = null; }); } catch (error) { if (!mounted || token != _loadToken) return; setState(() => _error = error); } } Future _openShop(Shop shop) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ShopDetailScreen(repository: widget.repository, shopId: shop.id), ), ); if (mounted) _load(); } Future _openBonusCard(Supermarket store) async { final path = store.bonusImagePath; if (path != null && path.isNotEmpty && File(path).existsSync()) { await _viewBonusCard(path); return; } await _pickBonusCard(store); } Future _viewBonusCard(String path) async { await showDialog( context: context, builder: (context) { return Dialog( insetPadding: const EdgeInsets.all(16), child: InteractiveViewer( child: Image.file(File(path), fit: BoxFit.contain), ), ); }, ); } Future _pickBonusCard(Supermarket store) 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 || store.id == null) return; final file = await _picker.pickImage(source: source, imageQuality: 85); if (file == null) return; await widget.repository.setSupermarketBonusCard(store.id!, file.path); if (mounted) _load(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: const Text('Home'), actions: const [ProfileButton(), SettingsButton()], ), body: _error != null && _overview == null ? Center( child: Text( 'Could not load an overview.', style: theme.textTheme.bodyLarge, ), ) : _overview == null ? const Center(child: CircularProgressIndicator()) : _HomeBody( overview: _overview!, stores: _stores, onOpenProducts: widget.onOpenProducts, onOpenLists: widget.onOpenLists, onOpenShopLog: widget.onOpenShopLog, onOpenAdd: widget.onOpenAdd, onOpenShop: _openShop, onOpenBonusCard: _openBonusCard, ), ); } } class _HomeBody extends StatelessWidget { const _HomeBody({ required this.overview, required this.stores, required this.onOpenProducts, required this.onOpenLists, required this.onOpenShopLog, required this.onOpenAdd, required this.onOpenShop, required this.onOpenBonusCard, }); final AppOverview overview; final List stores; final VoidCallback onOpenProducts; final VoidCallback onOpenLists; final VoidCallback onOpenShopLog; final VoidCallback onOpenAdd; final ValueChanged onOpenShop; final ValueChanged onOpenBonusCard; @override Widget build(BuildContext context) { final theme = Theme.of(context); final settingsStores = SettingsScope.maybeOf(context)?.supermarkets ?? stores; final empty = overview.productCount == 0 && overview.tripCount == 0 && overview.listCount == 0; return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ Text('Overview', style: theme.textTheme.titleMedium), const SizedBox(height: 8), if (empty) Card( child: Padding( padding: const EdgeInsets.all(20), child: Column( children: [ Text( 'Nothing logged yet.', style: theme.textTheme.titleMedium, ), const SizedBox(height: 8), Text( 'Scan a receipt or add a trip to see products, lists, and spending here.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium, ), const SizedBox(height: 16), FilledButton.icon( onPressed: onOpenAdd, icon: const Icon(Icons.add), label: const Text('Add a trip'), ), ], ), ), ) else ...[ Row( children: [ Expanded( child: _StatCard( icon: Icons.receipt_long_outlined, label: formatMonthYear(DateTime.now()), value: formatEuro(overview.monthTotal), detail: overview.monthTripCount == 0 ? 'No trips yet' : overview.monthTripCount == 1 ? '1 trip' : '${overview.monthTripCount} trips', onTap: onOpenShopLog, ), ), const SizedBox(width: 8), Expanded( child: _StatCard( icon: Icons.inventory_2_outlined, label: 'Products', value: '${overview.productCount}', detail: overview.favouriteCount == 0 ? 'No favourites' : overview.favouriteCount == 1 ? '1 favourite' : '${overview.favouriteCount} favourites', onTap: onOpenProducts, ), ), ], ), const SizedBox(height: 8), _StatCard( icon: Icons.checklist_outlined, label: 'Shopping lists', value: '${overview.listCount}', detail: overview.listCount == 1 ? '1 list' : 'Open your lists', onTap: onOpenLists, ), ], if (stores.isNotEmpty) ...[ const SizedBox(height: 24), Text('Bonus cards', style: theme.textTheme.titleMedium), const SizedBox(height: 8), SizedBox( height: 148, child: ListView.separated( key: ValueKey( stores.isEmpty ? 'bonus-empty' : 'bonus-${stores.first.name}', ), scrollDirection: Axis.horizontal, itemCount: stores.length, separatorBuilder: (_, _) => const SizedBox(width: 12), itemBuilder: (context, index) { final store = stores[index]; return _BonusCardTile( store: store, stores: settingsStores, onTap: () => onOpenBonusCard(store), ); }, ), ), ], if (overview.recentShops.isNotEmpty) ...[ const SizedBox(height: 24), Text('Recent trips', style: theme.textTheme.titleMedium), const SizedBox(height: 8), for (final shop in overview.recentShops) Padding( padding: const EdgeInsets.only(bottom: 4), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 8), title: shop.name == null || shop.name!.isEmpty ? StoreTag(name: shop.supermarket, stores: settingsStores) : Text(shop.name!), subtitle: Text( [ formatDate(shop.shoppedAt), formatEuro(shop.total), ].join(' ยท '), ), trailing: const Icon(Icons.chevron_right), onTap: () => onOpenShop(shop), ), ), ], ], ); } } class _BonusCardTile extends StatelessWidget { const _BonusCardTile({ required this.store, required this.stores, required this.onTap, }); final Supermarket store; final List stores; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; final path = store.bonusImagePath; final hasCard = path != null && path.isNotEmpty && File(path).existsSync(); return SizedBox( width: 220, child: Card( clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: !hasCard ? ColoredBox( color: store.color.withValues(alpha: 0.18), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.card_membership_outlined, color: store.color, size: 32, ), const SizedBox(height: 8), Text( 'Add bonus card', style: theme.textTheme.bodySmall, ), ], ), ), ) : Image.file( File(path), fit: BoxFit.cover, width: double.infinity, ), ), Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), child: Row( children: [ StoreTag(name: store.name, stores: stores, compact: true), const Spacer(), Icon( hasCard ? Icons.zoom_in : Icons.camera_alt_outlined, size: 16, color: scheme.onSurfaceVariant, ), ], ), ), ], ), ), ), ); } } class _StatCard extends StatelessWidget { const _StatCard({ required this.icon, required this.label, required this.value, required this.detail, required this.onTap, }); final IconData icon; final String label; final String value; final String detail; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; return Card( child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: scheme.primary), const SizedBox(height: 12), Text(label, style: theme.textTheme.bodyMedium), const SizedBox(height: 4), Text(value, style: theme.textTheme.titleLarge), const SizedBox(height: 2), Text(detail, style: theme.textTheme.bodyMedium), ], ), ), ), ); } }