/// Dashboard of catalog, trips, lists, and this month's spend. library; import 'package:flutter/material.dart'; import 'models/app_overview.dart'; import 'models/shop.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; Object? _error; int _loadToken = 0; @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(); if (!mounted || token != _loadToken) return; setState(() { _overview = overview; _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(); } @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!, onOpenProducts: widget.onOpenProducts, onOpenLists: widget.onOpenLists, onOpenShopLog: widget.onOpenShopLog, onOpenAdd: widget.onOpenAdd, onOpenShop: _openShop, ), ); } } class _HomeBody extends StatelessWidget { const _HomeBody({ required this.overview, required this.onOpenProducts, required this.onOpenLists, required this.onOpenShopLog, required this.onOpenAdd, required this.onOpenShop, }); final AppOverview overview; final VoidCallback onOpenProducts; final VoidCallback onOpenLists; final VoidCallback onOpenShopLog; final VoidCallback onOpenAdd; final ValueChanged onOpenShop; @override Widget build(BuildContext context) { final theme = Theme.of(context); final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; 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 (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: stores) : Text(shop.name!), subtitle: Text( [ formatDate(shop.shoppedAt), formatEuro(shop.total), ].join(' ยท '), ), trailing: const Icon(Icons.chevron_right), onTap: () => onOpenShop(shop), ), ), ], ], ); } } 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), ], ), ), ), ); } }