From 7692809219074ceea0ef53d1ba793af4efaff015 Mon Sep 17 00:00:00 2001 From: gitea Date: Mon, 31 Aug 2026 12:57:17 +0200 Subject: [PATCH] home added --- lib/home_screen.dart | 278 ++++++++++++++++++ lib/home_shell.dart | 71 +++-- lib/models/app_overview.dart | 24 ++ lib/models/shop.dart | 6 +- lib/products_screen.dart | 372 ++++++++++++++++-------- lib/profile_screen.dart | 44 +++ lib/scan_screen.dart | 5 +- lib/services/product_repository.dart | 168 ++++++++++- lib/shop_detail_screen.dart | 12 +- lib/shop_log_screen.dart | 404 +++++++++++++++++---------- lib/shopping_list_detail_screen.dart | 8 + lib/shopping_lists_screen.dart | 144 ++++++---- lib/widgets/profile_button.dart | 23 ++ pubspec.lock | 32 +++ pubspec.yaml | 1 + test/catalog_test.dart | 47 ++++ test/test_helpers.dart | 41 +++ test/widget_test.dart | 150 ++++++++-- 18 files changed, 1446 insertions(+), 384 deletions(-) create mode 100644 lib/home_screen.dart create mode 100644 lib/models/app_overview.dart create mode 100644 lib/profile_screen.dart create mode 100644 lib/widgets/profile_button.dart create mode 100644 test/catalog_test.dart create mode 100644 test/test_helpers.dart diff --git a/lib/home_screen.dart b/lib/home_screen.dart new file mode 100644 index 0000000..63276df --- /dev/null +++ b/lib/home_screen.dart @@ -0,0 +1,278 @@ +/// 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), + ], + ), + ), + ), + ); + } +} diff --git a/lib/home_shell.dart b/lib/home_shell.dart index 8359e8f..9efc7a8 100644 --- a/lib/home_shell.dart +++ b/lib/home_shell.dart @@ -1,8 +1,9 @@ -/// Bottom navigation: products, lists, add, and shop log. +/// Bottom navigation: home, products, lists, add, and shop log. library; import 'package:flutter/material.dart'; +import 'home_screen.dart'; import 'products_screen.dart'; import 'scan_screen.dart'; import 'services/app_settings.dart'; @@ -20,11 +21,14 @@ class HomeShell extends StatefulWidget { } class _HomeShellState extends State { + static const _homeIndex = 0; + static const _productsIndex = 1; static const _addIndex = 2; - static const _shopLogIndex = 3; + static const _listsIndex = 3; + static const _shopLogIndex = 4; static const _fabGap = 56.0; - int _index = _addIndex; + int _index = _homeIndex; int _revision = 0; AppSettings? _settings; int _seenGeneration = 0; @@ -77,15 +81,23 @@ class _HomeShellState extends State { return Scaffold( body: switch (_index) { - 0 => ProductsScreen( + _homeIndex => HomeScreen( + key: ValueKey('home-$_revision'), + repository: widget.repository, + onOpenProducts: () => _select(_productsIndex), + onOpenLists: () => _select(_listsIndex), + onOpenShopLog: () => _select(_shopLogIndex), + onOpenAdd: () => _select(_addIndex), + ), + _productsIndex => ProductsScreen( key: ValueKey('products-$_revision'), repository: widget.repository, ), - 1 => ShoppingListsScreen( + _listsIndex => ShoppingListsScreen( key: ValueKey('lists-$_revision'), repository: widget.repository, ), - 2 => ScanScreen( + _addIndex => ScanScreen( repository: widget.repository, onTripSaved: _onTripSaved, ), @@ -118,20 +130,20 @@ class _HomeShellState extends State { children: [ Expanded( child: _NavButton( - icon: Icons.inventory_2_outlined, - selectedIcon: Icons.inventory_2, - label: 'Products', - selected: _index == 0, - onTap: () => _select(0), + icon: Icons.home_outlined, + selectedIcon: Icons.home, + label: 'Home', + selected: _index == _homeIndex, + onTap: () => _select(_homeIndex), ), ), Expanded( child: _NavButton( - icon: Icons.checklist_outlined, - selectedIcon: Icons.checklist, - label: 'Lists', - selected: _index == 1, - onTap: () => _select(1), + icon: Icons.inventory_2_outlined, + selectedIcon: Icons.inventory_2, + label: 'Products', + selected: _index == _productsIndex, + onTap: () => _select(_productsIndex), ), ), ], @@ -139,12 +151,27 @@ class _HomeShellState extends State { ), const SizedBox(width: _fabGap), Expanded( - child: _NavButton( - icon: Icons.receipt_long_outlined, - selectedIcon: Icons.receipt_long, - label: 'Shop log', - selected: _index == _shopLogIndex, - onTap: () => _select(_shopLogIndex), + child: Row( + children: [ + Expanded( + child: _NavButton( + icon: Icons.checklist_outlined, + selectedIcon: Icons.checklist, + label: 'Lists', + selected: _index == _listsIndex, + onTap: () => _select(_listsIndex), + ), + ), + Expanded( + child: _NavButton( + icon: Icons.receipt_long_outlined, + selectedIcon: Icons.receipt_long, + label: 'Shop log', + selected: _index == _shopLogIndex, + onTap: () => _select(_shopLogIndex), + ), + ), + ], ), ), ], diff --git a/lib/models/app_overview.dart b/lib/models/app_overview.dart new file mode 100644 index 0000000..1d54114 --- /dev/null +++ b/lib/models/app_overview.dart @@ -0,0 +1,24 @@ +/// Counts and recent trips shown on the home dashboard. +library; + +import 'shop.dart'; + +class AppOverview { + const AppOverview({ + required this.productCount, + required this.favouriteCount, + required this.tripCount, + required this.monthTripCount, + required this.monthTotal, + required this.listCount, + required this.recentShops, + }); + + final int productCount; + final int favouriteCount; + final int tripCount; + final int monthTripCount; + final double monthTotal; + final int listCount; + final List recentShops; +} diff --git a/lib/models/shop.dart b/lib/models/shop.dart index c819ba0..5db661d 100644 --- a/lib/models/shop.dart +++ b/lib/models/shop.dart @@ -59,7 +59,7 @@ class ShopItem { const ShopItem({ required this.id, required this.shopId, - required this.productId, + this.productId, required this.name, required this.price, this.quantity, @@ -68,7 +68,7 @@ class ShopItem { final int id; final int shopId; - final int productId; + final int? productId; final String name; final double price; final int? quantity; @@ -78,7 +78,7 @@ class ShopItem { return ShopItem( id: map['id']! as int, shopId: map['shop_id']! as int, - productId: map['product_id']! as int, + productId: map['product_id'] as int?, name: map['name']! as String, price: (map['price'] as num).toDouble(), quantity: map['quantity'] as int?, diff --git a/lib/products_screen.dart b/lib/products_screen.dart index 7cb2075..53372d4 100644 --- a/lib/products_screen.dart +++ b/lib/products_screen.dart @@ -4,12 +4,15 @@ 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 'widgets/confirm_dialog.dart'; import 'widgets/product_image.dart'; +import 'widgets/profile_button.dart'; import 'widgets/settings_button.dart'; import 'widgets/store_tag.dart'; @@ -26,31 +29,52 @@ class _ProductsScreenState extends State { ProductSort _sort = ProductSort.preferred; bool _favouritesOnly = false; bool _searching = false; + bool _editing = false; final TextEditingController _search = TextEditingController(); - late Future> _future; + List? _products; + Object? _error; + int _loadToken = 0; + + bool get _hasProducts => _products != null && _products!.isNotEmpty; @override void initState() { super.initState(); - _future = _load(); + widget.repository.addListener(_load); _search.addListener(() => setState(() {})); + _load(); } @override void dispose() { + widget.repository.removeListener(_load); _search.dispose(); super.dispose(); } - Future> _load() { - return widget.repository.getAll( - sort: _sort, - favouritesOnly: _favouritesOnly, - ); - } - - void _reload() { - setState(() => _future = _load()); + Future _load() async { + final token = ++_loadToken; + try { + final products = await widget.repository.getAll( + sort: _sort, + favouritesOnly: _favouritesOnly, + ); + if (!mounted || token != _loadToken) return; + setState(() { + _products = products; + _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) { @@ -82,13 +106,88 @@ class _ProductsScreenState extends State { ), ), ); - if (mounted) _reload(); + 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(); + } + } + + 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 (_favouritesOnly && !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( @@ -106,17 +205,38 @@ class _ProductsScreenState extends State { ) : 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(), + 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) + 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( @@ -141,8 +261,8 @@ class _ProductsScreenState extends State { onSelectionChanged: (selected) { setState(() { _sort = selected.first; - _future = _load(); }); + _load(); }, ), ), @@ -153,8 +273,8 @@ class _ProductsScreenState extends State { onPressed: () { setState(() { _favouritesOnly = !_favouritesOnly; - _future = _load(); }); + _load(); }, icon: Icon( _favouritesOnly ? Icons.star : Icons.star_outline, @@ -163,93 +283,125 @@ class _ProductsScreenState extends State { ], ), ), - 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), - ); - }, - ); - }, - ), - ), + 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( + _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, + ), + 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, + ), + ), + ], + ) + : 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: _editing ? null : () => _openProduct(product), + ); + }, + ); + } } diff --git a/lib/profile_screen.dart b/lib/profile_screen.dart new file mode 100644 index 0000000..18f34f8 --- /dev/null +++ b/lib/profile_screen.dart @@ -0,0 +1,44 @@ +/// User profile placeholder. Sign-in will be added later. +library; + +import 'package:flutter/material.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Profile')), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 32), + children: [ + CircleAvatar( + radius: 40, + backgroundColor: scheme.secondaryContainer, + child: Icon( + Icons.person_outline, + size: 40, + color: scheme.onSecondaryContainer, + ), + ), + const SizedBox(height: 16), + Text( + 'Guest', + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Sign in, cloud sync, and account settings will show up here later.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ], + ), + ); + } +} diff --git a/lib/scan_screen.dart b/lib/scan_screen.dart index e18419a..1dddf42 100644 --- a/lib/scan_screen.dart +++ b/lib/scan_screen.dart @@ -16,6 +16,7 @@ import 'services/product_repository.dart'; import 'services/receipt_parser.dart'; import 'utils/constants.dart'; import 'utils/supermarkets.dart'; +import 'widgets/profile_button.dart'; import 'widgets/settings_button.dart'; class ScanScreen extends StatefulWidget { @@ -53,6 +54,8 @@ class _ScanScreenState extends State { } } on UnimplementedError { // Desktop / platforms that do not support lost-data recovery. + } on MissingPluginException { + // Widget tests and hosts without the image_picker plugin. } } @@ -182,7 +185,7 @@ class _ScanScreenState extends State { return Scaffold( appBar: AppBar( title: const Text(kAppName), - actions: const [SettingsButton()], + actions: const [ProfileButton(), SettingsButton()], ), body: SafeArea( child: Padding( diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index 136f234..1a789ce 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -3,10 +3,12 @@ library; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; +import '../models/app_overview.dart'; import '../models/line_item.dart'; import '../models/product.dart'; import '../models/purchase.dart'; @@ -16,15 +18,15 @@ import '../models/supermarket.dart'; import '../utils/money.dart'; import '../utils/supermarkets.dart'; -class ProductRepository { +class ProductRepository extends ChangeNotifier { ProductRepository({this.databasePath}); final String? databasePath; - Database? _db; + Future? _opening; - Future get _database async { - _db ??= await _open(); - return _db!; + Future get _database { + _opening ??= _open(); + return _opening!; } Future _open() async { @@ -33,7 +35,7 @@ class ProductRepository { p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); return openDatabase( path, - version: 6, + version: 7, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys = ON'); }, @@ -67,6 +69,9 @@ class ProductRepository { if (oldVersion < 6) { await _upgradeShopsToV6(db); } + if (oldVersion < 7) { + await _upgradeShopItemsProductFk(db); + } }, ); } @@ -135,13 +140,13 @@ class ProductRepository { CREATE TABLE shop_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_id INTEGER NOT NULL, - product_id INTEGER NOT NULL, + product_id INTEGER, name TEXT NOT NULL, price REAL NOT NULL, quantity INTEGER, unit_price REAL, FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE, - FOREIGN KEY (product_id) REFERENCES products(id) + FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL ) '''); await _createShopTagTable(db); @@ -169,6 +174,31 @@ class ProductRepository { await _createShopTagTable(db); } + Future _upgradeShopItemsProductFk(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE shop_items_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shop_id INTEGER NOT NULL, + product_id INTEGER, + name TEXT NOT NULL, + price REAL NOT NULL, + quantity INTEGER, + unit_price REAL, + FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL + ) + '''); + await db.execute(''' + INSERT INTO shop_items_new ( + id, shop_id, product_id, name, price, quantity, unit_price + ) + SELECT id, shop_id, product_id, name, price, quantity, unit_price + FROM shop_items + '''); + await db.execute('DROP TABLE shop_items'); + await db.execute('ALTER TABLE shop_items_new RENAME TO shop_items'); + } + Future _createShoppingListTables(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS shopping_lists ( @@ -268,6 +298,7 @@ class ProductRepository { } } + notifyListeners(); return shopId; } @@ -353,6 +384,51 @@ class ProductRepository { return rows.map(Product.fromMap).toList(); } + Future getOverview() async { + final db = await _database; + final productCount = + Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM products'), + ) ?? + 0; + final favouriteCount = + Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT COUNT(*) FROM products WHERE favourite = 1', + ), + ) ?? + 0; + final tripCount = + Sqflite.firstIntValue(await db.rawQuery('SELECT COUNT(*) FROM shops')) ?? + 0; + final listCount = + Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM shopping_lists'), + ) ?? + 0; + final now = DateTime.now(); + final monthStart = DateTime(now.year, now.month, 1).toIso8601String(); + final monthRows = await db.rawQuery( + ''' + SELECT COUNT(*) AS trip_count, COALESCE(SUM(total), 0) AS month_total + FROM shops + WHERE shopped_at >= ? + ''', + [monthStart], + ); + final month = monthRows.first; + final shops = await getShops(); + return AppOverview( + productCount: productCount, + favouriteCount: favouriteCount, + tripCount: tripCount, + monthTripCount: (month['trip_count'] as num?)?.toInt() ?? 0, + monthTotal: (month['month_total'] as num?)?.toDouble() ?? 0, + listCount: listCount, + recentShops: shops.take(3).toList(), + ); + } + Future getProduct(int id) async { final db = await _database; final rows = await db.rawQuery( @@ -433,6 +509,7 @@ class ProductRepository { values['category'] = category.trim(); } await db.update('products', values, where: 'id = ?', whereArgs: [id]); + notifyListeners(); } Future setFavourite(int id, bool favourite) => @@ -444,6 +521,60 @@ class ProductRepository { return stored; } + Future deleteProduct(int id) async { + final db = await _database; + final rows = await db.query( + 'products', + columns: ['image_path'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + await db.transaction((txn) async { + await txn.update( + 'shop_items', + {'product_id': null}, + where: 'product_id = ?', + whereArgs: [id], + ); + await txn.update( + 'shopping_list_items', + {'product_id': null}, + where: 'product_id = ?', + whereArgs: [id], + ); + await txn.delete('products', where: 'id = ?', whereArgs: [id]); + }); + if (rows.isNotEmpty) { + await _tryDeleteFile(rows.first['image_path'] as String?); + } + notifyListeners(); + } + + Future deleteAllProducts() async { + final db = await _database; + final rows = await db.query('products', columns: ['image_path']); + await db.transaction((txn) async { + await txn.update('shop_items', {'product_id': null}); + await txn.update('shopping_list_items', {'product_id': null}); + await txn.delete('products'); + }); + for (final row in rows) { + await _tryDeleteFile(row['image_path'] as String?); + } + notifyListeners(); + } + + Future _tryDeleteFile(String? path) async { + if (path == null || path.isEmpty) return; + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + Future> getPurchases(int productId) async { final db = await _database; final rows = await db.rawQuery( @@ -576,6 +707,7 @@ class ProductRepository { await _replaceShopTags(txn, shopId: id, tags: tags); } }); + notifyListeners(); } Future _replaceShopTags( @@ -624,12 +756,8 @@ class ProductRepository { await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]); await txn.delete('shops', where: 'id = ?', whereArgs: [id]); }); - final path = shop?.receiptImagePath; - if (path == null || path.isEmpty) return; - final file = File(path); - if (await file.exists()) { - await file.delete(); - } + await _tryDeleteFile(shop?.receiptImagePath); + notifyListeners(); } /// Removes one trip line. Catalog products are left unchanged. @@ -646,6 +774,7 @@ class ProductRepository { final shopId = rows.first['shop_id']! as int; await db.delete('shop_items', where: 'id = ?', whereArgs: [id]); await _recalculateShopTotal(shopId); + notifyListeners(); } Future updateShopItem({ @@ -672,6 +801,7 @@ class ProductRepository { if (values.isEmpty) return; await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]); await _recalculateShopTotal(shopId); + notifyListeners(); } Future _recalculateShopTotal(int shopId) async { @@ -732,11 +862,13 @@ class ProductRepository { Future createShoppingList(String name) async { final db = await _database; final now = DateTime.now().toIso8601String(); - return db.insert('shopping_lists', { + final id = await db.insert('shopping_lists', { 'name': name.trim(), 'created_at': now, 'updated_at': now, }); + notifyListeners(); + return id; } Future renameShoppingList(int id, String name) async { @@ -747,6 +879,7 @@ class ProductRepository { where: 'id = ?', whereArgs: [id], ); + notifyListeners(); } Future deleteShoppingList(int id) async { @@ -759,6 +892,7 @@ class ProductRepository { ); await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]); }); + notifyListeners(); } Future> getShoppingListItems(int listId) async { @@ -792,6 +926,7 @@ class ProductRepository { 'sort_order': (max ?? -1) + 1, }); await _touchShoppingList(listId); + notifyListeners(); return id; } @@ -822,6 +957,7 @@ class ProductRepository { whereArgs: [id], ); await _touchShoppingList(rows.first['list_id']! as int); + notifyListeners(); } Future deleteShoppingListItem(int id) async { @@ -836,6 +972,7 @@ class ProductRepository { if (rows.isEmpty) return; await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]); await _touchShoppingList(rows.first['list_id']! as int); + notifyListeners(); } Future _touchShoppingList(int id) async { @@ -1026,6 +1163,7 @@ class ProductRepository { await _resetSequence(txn, 'shopping_lists'); await _resetSequence(txn, 'shopping_list_items'); }); + notifyListeners(); } static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'}; diff --git a/lib/shop_detail_screen.dart b/lib/shop_detail_screen.dart index 658ad7e..7f8c072 100644 --- a/lib/shop_detail_screen.dart +++ b/lib/shop_detail_screen.dart @@ -39,6 +39,7 @@ class _ShopDetailScreenState extends State { void initState() { super.initState(); _future = _load(); + widget.repository.addListener(_reload); widget.repository .getUsedShopTags() .then((tags) { @@ -47,6 +48,12 @@ class _ShopDetailScreenState extends State { .catchError((_) {}); } + @override + void dispose() { + widget.repository.removeListener(_reload); + super.dispose(); + } + Future<({Shop shop, List items})> _load() async { final shop = await widget.repository.getShop(widget.shopId); if (shop == null) { @@ -57,6 +64,7 @@ class _ShopDetailScreenState extends State { } void _reload() { + if (!mounted) return; setState(() => _future = _load()); } @@ -276,7 +284,9 @@ class _ShopDetailScreenState extends State { formatEuro(item.price), style: theme.textTheme.titleMedium, ), - onTap: _editing ? null : () => _openProduct(item.productId), + onTap: _editing || item.productId == null + ? null + : () => _openProduct(item.productId!), ), const Divider(), ListTile( diff --git a/lib/shop_log_screen.dart b/lib/shop_log_screen.dart index debb8d3..2a005e4 100644 --- a/lib/shop_log_screen.dart +++ b/lib/shop_log_screen.dart @@ -13,6 +13,7 @@ import 'utils/money.dart'; import 'utils/shop_calendar.dart'; import 'utils/shop_log_filter.dart'; import 'widgets/confirm_dialog.dart'; +import 'widgets/profile_button.dart'; import 'widgets/settings_button.dart'; import 'widgets/shop_heatmap.dart'; import 'widgets/store_tag.dart'; @@ -28,8 +29,10 @@ class ShopLogScreen extends StatefulWidget { } class _ShopLogScreenState extends State { - late Future<({List shops, Map> productNames})> - _future; + List? _shops; + Map> _productNames = const {}; + Object? _error; + int _loadToken = 0; final _search = TextEditingController(); bool _editing = false; bool _searching = false; @@ -37,28 +40,45 @@ class _ShopLogScreenState extends State { DateTime? _selectedDay; String? _tagFilter; + bool get _hasTrips => _shops != null && _shops!.isNotEmpty; + @override void initState() { super.initState(); - _future = _load(); + widget.repository.addListener(_load); _search.addListener(() => setState(() {})); + _load(); } @override void dispose() { + widget.repository.removeListener(_load); _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 _load() async { + final token = ++_loadToken; + try { + final shops = await widget.repository.getShops(); + final productNames = await widget.repository.getShopItemNamesByShop(); + if (!mounted || token != _loadToken) return; + setState(() { + _shops = shops; + _productNames = productNames; + _error = null; + if (shops.isEmpty) { + _editing = false; + if (_searching) { + _searching = false; + _search.clear(); + } + } + }); + } catch (error) { + if (!mounted || token != _loadToken) return; + setState(() => _error = error); + } } Future _openShop(Shop shop) async { @@ -68,18 +88,31 @@ class _ShopLogScreenState extends State { ShopDetailScreen(repository: widget.repository, shopId: shop.id), ), ); - if (mounted) _reload(); + if (mounted) _load(); } 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.', + 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(); + setState(() { + _shops = [for (final item in _shops ?? const []) if (item.id != shop.id) item]; + _productNames = Map.of(_productNames)..remove(shop.id); + if (_shops!.isEmpty) { + _editing = false; + _searching = false; + _search.clear(); + } + }); + try { + await widget.repository.deleteShop(shop.id); + } catch (_) { + if (mounted) _load(); + } } void _selectDay(DateTime? day) { @@ -120,142 +153,205 @@ class _ShopLogScreenState extends State { actions: [ IconButton( tooltip: _searching ? 'Close search' : 'Search products', - onPressed: () { - setState(() { - _searching = !_searching; - if (!_searching) _search.clear(); - }); - }, + onPressed: _hasTrips + ? () { + setState(() { + _searching = !_searching; + if (!_searching) _search.clear(); + }); + } + : null, icon: Icon(_searching ? Icons.close : Icons.search), ), - IconButton( - tooltip: _editing ? 'Done' : 'Edit', - onPressed: () => setState(() => _editing = !_editing), - icon: Icon(_editing ? Icons.check : Icons.edit_outlined), - ), + if (_hasTrips) + IconButton( + tooltip: _editing ? 'Done' : 'Edit', + onPressed: () => setState(() => _editing = !_editing), + icon: Icon(_editing ? Icons.check : Icons.edit_outlined), + ), + const ProfileButton(), 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; - }); - }, - ), - ), - ], - ); + body: _ShopLogBody( + theme: theme, + stores: stores, + nowMonth: nowMonth, + shops: _shops, + productNames: _productNames, + error: _error, + month: _month, + selectedDay: _selectedDay, + tagFilter: _tagFilter, + query: _search.text, + editing: _editing, + onPreviousMonth: () => _goMonth(-1), + onNextMonth: () => _goMonth(1), + onSelectDay: _selectDay, + onClearTag: () => setState(() => _tagFilter = null), + onTagTap: (tag) { + setState(() { + _tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase() + ? null + : tag; + }); }, + onOpen: _openShop, + onRemove: _removeShop, ), ); } } +class _ShopLogBody extends StatelessWidget { + const _ShopLogBody({ + required this.theme, + required this.stores, + required this.nowMonth, + required this.shops, + required this.productNames, + required this.error, + required this.month, + required this.selectedDay, + required this.tagFilter, + required this.query, + required this.editing, + required this.onPreviousMonth, + required this.onNextMonth, + required this.onSelectDay, + required this.onClearTag, + required this.onTagTap, + required this.onOpen, + required this.onRemove, + }); + + final ThemeData theme; + final List stores; + final DateTime nowMonth; + final List? shops; + final Map> productNames; + final Object? error; + final DateTime month; + final DateTime? selectedDay; + final String? tagFilter; + final String query; + final bool editing; + final VoidCallback onPreviousMonth; + final VoidCallback onNextMonth; + final ValueChanged onSelectDay; + final VoidCallback onClearTag; + final ValueChanged onTagTap; + final ValueChanged onOpen; + final ValueChanged onRemove; + + @override + Widget build(BuildContext context) { + if (error != null && shops == null) { + return Center( + child: Text( + 'Could not load the shop log.', + style: theme.textTheme.bodyLarge, + ), + ); + } + if (shops == null) { + return const Center(child: CircularProgressIndicator()); + } + + final allShops = shops!; + final counts = tripCountsByDay([ + for (final shop in allShops) + if (isSameMonth(shop.shoppedAt, month)) shop.shoppedAt, + ]); + final monthShops = [ + for (final shop in allShops) + if (isSameMonth(shop.shoppedAt, month)) shop, + ]; + final monthTotal = monthShops.fold( + 0, + (sum, shop) => sum + shop.total, + ); + final visible = filterShops( + shops: allShops, + productNames: productNames, + selectedDay: selectedDay, + tag: tagFilter, + query: query, + ); + + 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: onPreviousMonth, + onNextMonth: onNextMonth, + onSelectDay: onSelectDay, + ), + 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: () => onSelectDay(null), + ), + if (tagFilter != null) + InputChip( + label: Text(tagFilter!), + onDeleted: onClearTag, + ), + ], + ), + ), + if (allShops.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( + query.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: () => onOpen(shop), + onRemove: () => onRemove(shop), + onTagTap: onTagTap, + ), + ), + ], + ); + } +} + class _ShopLogTile extends StatelessWidget { const _ShopLogTile({ required this.shop, @@ -295,6 +391,12 @@ class _ShopLogTile extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (editing) + IconButton( + tooltip: 'Remove', + onPressed: onRemove, + icon: const Icon(Icons.delete_outline), + ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -326,19 +428,13 @@ class _ShopLogTile extends StatelessWidget { ), ), 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, - ), - ), + Padding( + padding: const EdgeInsets.only(top: 8, right: 8), + child: Text( + formatEuro(shop.total), + style: theme.textTheme.titleMedium, + ), + ), ], ), ), diff --git a/lib/shopping_list_detail_screen.dart b/lib/shopping_list_detail_screen.dart index 759ae5c..521b654 100644 --- a/lib/shopping_list_detail_screen.dart +++ b/lib/shopping_list_detail_screen.dart @@ -33,6 +33,13 @@ class _ShoppingListDetailScreenState extends State { void initState() { super.initState(); _future = _load(); + widget.repository.addListener(_reload); + } + + @override + void dispose() { + widget.repository.removeListener(_reload); + super.dispose(); } Future<({ShoppingList list, List items})> _load() async { @@ -45,6 +52,7 @@ class _ShoppingListDetailScreenState extends State { } void _reload() { + if (!mounted) return; setState(() => _future = _load()); } diff --git a/lib/shopping_lists_screen.dart b/lib/shopping_lists_screen.dart index 8c7391f..efa25b3 100644 --- a/lib/shopping_lists_screen.dart +++ b/lib/shopping_lists_screen.dart @@ -8,6 +8,7 @@ import 'services/product_repository.dart'; import 'shopping_list_detail_screen.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/name_dialog.dart'; +import 'widgets/profile_button.dart'; import 'widgets/settings_button.dart'; class ShoppingListsScreen extends StatefulWidget { @@ -20,17 +21,40 @@ class ShoppingListsScreen extends StatefulWidget { } class _ShoppingListsScreenState extends State { - late Future> _future; + List? _lists; + Object? _error; bool _editing = false; + int _loadToken = 0; + + bool get _hasLists => _lists != null && _lists!.isNotEmpty; @override void initState() { super.initState(); - _future = widget.repository.getShoppingLists(); + widget.repository.addListener(_load); + _load(); } - void _reload() { - setState(() => _future = widget.repository.getShoppingLists()); + @override + void dispose() { + widget.repository.removeListener(_load); + super.dispose(); + } + + Future _load() async { + final token = ++_loadToken; + try { + final lists = await widget.repository.getShoppingLists(); + if (!mounted || token != _loadToken) return; + setState(() { + _lists = lists; + _error = null; + if (lists.isEmpty) _editing = false; + }); + } catch (error) { + if (!mounted || token != _loadToken) return; + setState(() => _error = error); + } } Future _create() async { @@ -53,7 +77,7 @@ class _ShoppingListsScreenState extends State { ShoppingListDetailScreen(repository: widget.repository, listId: id), ), ); - if (mounted) _reload(); + if (mounted) _load(); } Future _removeList(ShoppingList list) async { @@ -63,8 +87,15 @@ class _ShoppingListsScreenState extends State { message: '"${list.name}" and its items will be deleted.', ); if (!confirmed || !mounted) return; - await widget.repository.deleteShoppingList(list.id); - if (mounted) _reload(); + setState(() { + _lists = [for (final item in _lists ?? const []) if (item.id != list.id) item]; + if (_lists!.isEmpty) _editing = false; + }); + try { + await widget.repository.deleteShoppingList(list.id); + } catch (_) { + if (mounted) _load(); + } } @override @@ -75,11 +106,13 @@ class _ShoppingListsScreenState extends State { appBar: AppBar( title: const Text('Shopping lists'), actions: [ - IconButton( - tooltip: _editing ? 'Done' : 'Edit', - onPressed: () => setState(() => _editing = !_editing), - icon: Icon(_editing ? Icons.check : Icons.edit_outlined), - ), + if (_hasLists) + IconButton( + tooltip: _editing ? 'Done' : 'Edit', + onPressed: () => setState(() => _editing = !_editing), + icon: Icon(_editing ? Icons.check : Icons.edit_outlined), + ), + const ProfileButton(), const SettingsButton(), ], ), @@ -88,64 +121,53 @@ class _ShoppingListsScreenState extends State { tooltip: 'New list', child: const Icon(Icons.add), ), - body: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( + body: _error != null && _lists == null + ? Center( child: Text( 'Could not load shopping lists.', style: theme.textTheme.bodyLarge, ), - ); - } - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - - final lists = snapshot.data!; - if (lists.isEmpty) { - return Center( + ) + : _lists == null + ? const Center(child: CircularProgressIndicator()) + : _lists!.isEmpty + ? Center( child: Text( 'Create a list for your next shop.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), - ); - } - - return ListView.separated( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), - itemCount: lists.length, - separatorBuilder: (_, _) => const SizedBox(height: 4), - itemBuilder: (context, index) { - final list = lists[index]; - final remaining = list.itemCount - list.checkedCount; - return ListTile( - leading: CircleAvatar(child: Text('${list.itemCount}')), - title: Text(list.name), - subtitle: Text( - list.itemCount == 0 - ? 'Empty' - : remaining == 0 - ? 'All done' - : remaining == 1 - ? '1 item left' - : '$remaining items left', - ), - trailing: _editing - ? IconButton( - tooltip: 'Delete', - onPressed: () => _removeList(list), - icon: const Icon(Icons.delete_outline), - ) - : const Icon(Icons.chevron_right), - onTap: _editing ? null : () => _openList(list.id), - ); - }, - ); - }, - ), + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), + itemCount: _lists!.length, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final list = _lists![index]; + final remaining = list.itemCount - list.checkedCount; + return ListTile( + leading: CircleAvatar(child: Text('${list.itemCount}')), + title: Text(list.name), + subtitle: Text( + list.itemCount == 0 + ? 'Empty' + : remaining == 0 + ? 'All done' + : remaining == 1 + ? '1 item left' + : '$remaining items left', + ), + trailing: _editing + ? IconButton( + tooltip: 'Delete', + onPressed: () => _removeList(list), + icon: const Icon(Icons.delete_outline), + ) + : const Icon(Icons.chevron_right), + onTap: _editing ? null : () => _openList(list.id), + ); + }, + ), ); } } diff --git a/lib/widgets/profile_button.dart b/lib/widgets/profile_button.dart new file mode 100644 index 0000000..de88a38 --- /dev/null +++ b/lib/widgets/profile_button.dart @@ -0,0 +1,23 @@ +/// Opens the profile screen from an app bar. +library; + +import 'package:flutter/material.dart'; + +import '../profile_screen.dart'; + +class ProfileButton extends StatelessWidget { + const ProfileButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: 'Profile', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ); + }, + icon: const Icon(Icons.person_outline), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index a3bfaed..b21b18e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -256,6 +256,14 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" google_mlkit_commons: dependency: transitive description: @@ -464,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" objective_c: dependency: transitive description: @@ -637,6 +653,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.11" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: d5564f1308cafbf064be498f2beb1e993e742985a7cca9e2e228d6cbccd84a05 + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" sqflite_darwin: dependency: transitive description: @@ -653,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d1b1a09..4ee794d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + sqflite_common_ffi: ^2.4.0 flutter: uses-material-design: true diff --git a/test/catalog_test.dart b/test/catalog_test.dart new file mode 100644 index 0000000..6fe2e0e --- /dev/null +++ b/test/catalog_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:receipity/models/line_item.dart'; + +import 'test_helpers.dart'; + +void main() { + ensureTestSqlite(); + + test('deleting a product keeps the trip line and drops the catalog row', + () async { + final repository = memoryRepository(); + final shopId = await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 12), + items: [LineItem(id: '1', name: 'Milk', price: 1.5)], + ); + + final products = await repository.getAll(); + expect(products, hasLength(1)); + await repository.deleteProduct(products.single.id); + + expect(await repository.getAll(), isEmpty); + final items = await repository.getShopItems(shopId); + expect(items, hasLength(1)); + expect(items.single.name, 'Milk'); + expect(items.single.productId, isNull); + }); + + test('deleting all products leaves shopping trips in place', () async { + final repository = memoryRepository(); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 12), + items: [ + LineItem(id: '1', name: 'Milk', price: 1.5), + LineItem(id: '2', name: 'Bread', price: 2), + ], + ); + await repository.deleteAllProducts(); + + expect(await repository.getAll(), isEmpty); + expect(await repository.getShops(), hasLength(1)); + final overview = await repository.getOverview(); + expect(overview.productCount, 0); + expect(overview.tripCount, 1); + }); +} diff --git a/test/test_helpers.dart b/test/test_helpers.dart new file mode 100644 index 0000000..99f0bb3 --- /dev/null +++ b/test/test_helpers.dart @@ -0,0 +1,41 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:receipity/services/product_repository.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +var _dbSeq = 0; +var _sqliteReady = false; + +void ensureTestSqlite() { + if (_sqliteReady) return; + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + _sqliteReady = true; +} + +ProductRepository memoryRepository() { + ensureTestSqlite(); + final dir = Directory(p.join('.dart_tool', 'receipity_test_db')) + ..createSync(recursive: true); + final name = + 'db_${DateTime.now().microsecondsSinceEpoch}_${_dbSeq++}_${Random().nextInt(1 << 32)}.sqlite'; + return ProductRepository(databasePath: p.join(dir.path, name)); +} + +Future pumpUntilFound( + WidgetTester tester, + Finder finder, { + int attempts = 40, +}) async { + for (var i = 0; i < attempts; i++) { + await tester.pump(); + if (finder.evaluate().isNotEmpty) return; + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + } + throw TestFailure('Timed out waiting for $finder'); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 6a7127d..61e3ed9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,14 +3,34 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:receipity/main.dart'; import 'package:receipity/models/line_item.dart'; import 'package:receipity/review_screen.dart'; -import 'package:receipity/services/product_repository.dart'; import 'package:receipity/widgets/shop_heatmap.dart'; +import 'test_helpers.dart'; + void main() { - testWidgets('scan screen shows capture actions and bottom navigation', ( + ensureTestSqlite(); + + testWidgets('home screen shows overview and bottom navigation', ( tester, ) async { - await tester.pumpWidget(ReceipityApp()); + await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await pumpUntilFound(tester, find.text('Nothing logged yet.')); + + expect(find.text('Home'), findsWidgets); + expect(find.text('Products'), findsOneWidget); + expect(find.text('Lists'), findsOneWidget); + expect(find.byTooltip('Add'), findsOneWidget); + expect(find.byIcon(Icons.add), findsWidgets); + expect(find.text('Shop log'), findsOneWidget); + expect(find.byTooltip('Profile'), findsOneWidget); + expect(find.byTooltip('Settings'), findsOneWidget); + }); + + testWidgets('scan screen shows capture actions', (tester) async { + await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await pumpUntilFound(tester, find.text('Overview')); + await tester.tap(find.byTooltip('Add')); + await tester.pump(); expect(find.text('Receipity'), findsOneWidget); expect(find.text('Take photo'), findsOneWidget); @@ -21,18 +41,111 @@ void main() { find.textContaining('Scan a receipt or enter a trip by hand'), findsOneWidget, ); - expect(find.text('Products'), findsOneWidget); - expect(find.text('Lists'), findsOneWidget); - expect(find.byTooltip('Add'), findsOneWidget); - expect(find.byIcon(Icons.add), findsOneWidget); - expect(find.text('Shop log'), findsOneWidget); }); - testWidgets('shop log has product search', (tester) async { - await tester.pumpWidget(ReceipityApp()); + testWidgets('empty shop log hides edit and disables search', (tester) async { + await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Shop log')); - await tester.pump(); + await pumpUntilFound( + tester, + find.text('Scan a receipt or enter a trip to log it.'), + ); + + expect(find.byTooltip('Edit'), findsNothing); expect(find.byTooltip('Search products'), findsOneWidget); + expect( + tester + .widget( + find.ancestor( + of: find.byIcon(Icons.search), + matching: find.byType(IconButton), + ), + ) + .onPressed, + isNull, + ); + }); + + testWidgets('deleting a trip removes it without leaving the screen', ( + tester, + ) async { + final repository = memoryRepository(); + await tester.runAsync(() async { + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime.now(), + name: 'Weekly shop', + items: [LineItem(id: '1', name: 'Milk', price: 1.5)], + ); + }); + + await tester.pumpWidget(ReceipityApp(repository: repository)); + await pumpUntilFound(tester, find.text('Overview')); + await tester.tap(find.text('Shop log')); + await pumpUntilFound(tester, find.text('Weekly shop')); + + await tester.tap(find.byTooltip('Edit')); + await tester.pump(); + await tester.tap(find.byTooltip('Remove')); + await tester.pump(); + await tester.tap(find.text('Delete')); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(); + + expect(find.text('Weekly shop'), findsNothing); + expect( + find.text('Scan a receipt or enter a trip to log it.'), + findsOneWidget, + ); + expect(find.byTooltip('Edit'), findsNothing); + }); + + testWidgets('products edit mode shows delete, star, and wipe all', ( + tester, + ) async { + final repository = memoryRepository(); + await tester.runAsync(() async { + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime.now(), + items: [LineItem(id: '1', name: 'Milk', price: 1.5)], + ); + }); + + await tester.pumpWidget(ReceipityApp(repository: repository)); + await pumpUntilFound(tester, find.text('Overview')); + await tester.tap(find.text('Products').last); + await pumpUntilFound(tester, find.text('Milk')); + + await tester.tap(find.byTooltip('Edit')); + await tester.pump(); + + expect(find.byTooltip('Delete'), findsOneWidget); + expect(find.byTooltip('Favourite'), findsOneWidget); + expect(find.byTooltip('Delete all products'), findsOneWidget); + + await tester.tap(find.byTooltip('Delete all products')); + await tester.pump(); + await tester.tap(find.text('Delete')); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(); + + expect(find.text('Milk'), findsNothing); + expect(find.byTooltip('Edit'), findsNothing); + }); + + testWidgets('profile screen is a placeholder', (tester) async { + await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await pumpUntilFound(tester, find.text('Overview')); + await tester.tap(find.byTooltip('Profile')); + await pumpUntilFound(tester, find.text('Guest')); }); testWidgets('shop heatmap renders the visible month', (tester) async { @@ -59,15 +172,18 @@ void main() { }); testWidgets('enter manually opens a blank trip', (tester) async { - await tester.pumpWidget(ReceipityApp()); + await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await pumpUntilFound(tester, find.text('Overview')); + await tester.tap(find.byTooltip('Add')); + await tester.pump(); await tester.tap(find.text('Enter manually')); - await tester.pumpAndSettle(); - expect(find.text('New trip'), findsOneWidget); + await pumpUntilFound(tester, find.text('New trip')); expect(find.text('Add item'), findsOneWidget); }); testWidgets('lists tab shows shopping lists', (tester) async { - await tester.pumpWidget(ReceipityApp()); + await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Lists')); await tester.pump(); expect(find.text('Shopping lists'), findsOneWidget); @@ -82,7 +198,7 @@ void main() { LineItem(id: '2', name: 'Bread', price: 2), ], rawText: 'Milk 1.50\nBread 2.00', - repository: ProductRepository(databasePath: ':memory:'), + repository: memoryRepository(), ), ), ); @@ -109,7 +225,7 @@ void main() { home: ReviewScreen( items: [LineItem(id: '1', name: 'Milk', price: 1.5)], rawText: 'Jumbo\nMilk 1.50', - repository: ProductRepository(databasePath: ':memory:'), + repository: memoryRepository(), ), ), );