From 756f9db356bdc295fdcff7649a9c25f89caf5b14 Mon Sep 17 00:00:00 2001 From: gitea Date: Mon, 31 Aug 2026 16:51:31 +0200 Subject: [PATCH] fixes before build --- lib/home_screen.dart | 174 +++++- lib/models/line_item.dart | 8 + lib/models/product.dart | 2 +- lib/models/shopping_list.dart | 5 + lib/models/supermarket.dart | 8 + lib/product_detail_screen.dart | 409 +++++++------- lib/products_screen.dart | 161 +++--- lib/review_screen.dart | 15 +- lib/services/app_settings.dart | 4 +- lib/services/backup_service.dart | 1 + lib/services/product_repository.dart | 491 +++++++++++++++-- lib/shop_detail_screen.dart | 202 +++++-- lib/shop_log_screen.dart | 13 +- lib/shopping_list_detail_screen.dart | 499 +++++++++-------- lib/shopping_lists_screen.dart | 8 +- lib/supermarkets_screen.dart | 284 ++++++++++ lib/utils/app_theme.dart | 20 +- lib/utils/product_names.dart | 269 ++++++++++ lib/widgets/add_list_items_sheet.dart | 175 ++++++ lib/widgets/discounted_price.dart | 37 ++ lib/widgets/item_edit_sheet.dart | 3 + lib/widgets/pick_shopping_list_sheet.dart | 90 ++++ lib/widgets/price_chart.dart | 11 +- lib/widgets/product_image.dart | 4 +- lib/widgets/product_name_conflicts.dart | 618 ++++++++++++++++++++++ lib/widgets/profile_button.dart | 6 +- lib/widgets/settings_button.dart | 317 +++-------- lib/widgets/store_edit_dialog.dart | 119 +++++ test/catalog_test.dart | 44 +- test/fuzzy_test.dart | 5 +- test/product_names_test.dart | 198 +++++++ test/receipt_parser_test.dart | 1 + test/test_helpers.dart | 67 +++ test/widget_test.dart | 111 ++++ 34 files changed, 3542 insertions(+), 837 deletions(-) create mode 100644 lib/supermarkets_screen.dart create mode 100644 lib/utils/product_names.dart create mode 100644 lib/widgets/add_list_items_sheet.dart create mode 100644 lib/widgets/discounted_price.dart create mode 100644 lib/widgets/pick_shopping_list_sheet.dart create mode 100644 lib/widgets/product_name_conflicts.dart create mode 100644 lib/widgets/store_edit_dialog.dart create mode 100644 test/product_names_test.dart diff --git a/lib/home_screen.dart b/lib/home_screen.dart index 63276df..1e5ef13 100644 --- a/lib/home_screen.dart +++ b/lib/home_screen.dart @@ -1,10 +1,14 @@ /// 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'; @@ -36,8 +40,10 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { AppOverview? _overview; + List _stores = const []; Object? _error; int _loadToken = 0; + final ImagePicker _picker = ImagePicker(); @override void initState() { @@ -56,9 +62,11 @@ class _HomeScreenState extends State { final token = ++_loadToken; try { final overview = await widget.repository.getOverview(); + final stores = await widget.repository.getSupermarkets(); if (!mounted || token != _loadToken) return; setState(() { _overview = overview; + _stores = stores; _error = null; }); } catch (error) { @@ -77,6 +85,60 @@ class _HomeScreenState extends State { 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); @@ -97,11 +159,13 @@ class _HomeScreenState extends State { ? 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, ), ); } @@ -110,24 +174,29 @@ class _HomeScreenState extends State { 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 stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; + final settingsStores = + SettingsScope.maybeOf(context)?.supermarkets ?? stores; final empty = overview.productCount == 0 && overview.tripCount == 0 && @@ -205,6 +274,27 @@ class _HomeBody extends StatelessWidget { 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( + 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), @@ -215,7 +305,7 @@ class _HomeBody extends StatelessWidget { child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 8), title: shop.name == null || shop.name!.isEmpty - ? StoreTag(name: shop.supermarket, stores: stores) + ? StoreTag(name: shop.supermarket, stores: settingsStores) : Text(shop.name!), subtitle: Text( [ @@ -233,6 +323,86 @@ class _HomeBody extends StatelessWidget { } } +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, diff --git a/lib/models/line_item.dart b/lib/models/line_item.dart index 767eb53..1b88e96 100644 --- a/lib/models/line_item.dart +++ b/lib/models/line_item.dart @@ -22,6 +22,14 @@ class LineItem { /// Negative amount when a discount was folded into [price]. final double discount; + bool get hasDiscount => discount != 0; + + /// List price before a folded discount. Equals [price] when there is none. + double get originalPrice { + if (!hasDiscount) return price; + return ((price - discount) * 100).round() / 100; + } + LineItem copyWith({ String? id, String? name, diff --git a/lib/models/product.dart b/lib/models/product.dart index 907a95a..7a9edcd 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -72,4 +72,4 @@ class Product { } } -enum ProductSort { name, preferred } +enum ProductSort { name, preferred, start } diff --git a/lib/models/shopping_list.dart b/lib/models/shopping_list.dart index 6c3756e..9d4b852 100644 --- a/lib/models/shopping_list.dart +++ b/lib/models/shopping_list.dart @@ -7,6 +7,7 @@ class ShoppingList { required this.name, required this.itemCount, required this.checkedCount, + required this.createdAt, required this.updatedAt, }); @@ -14,6 +15,7 @@ class ShoppingList { final String name; final int itemCount; final int checkedCount; + final DateTime createdAt; final DateTime updatedAt; factory ShoppingList.fromMap(Map map) { @@ -22,6 +24,9 @@ class ShoppingList { name: map['name']! as String, itemCount: (map['item_count'] as num?)?.toInt() ?? 0, checkedCount: (map['checked_count'] as num?)?.toInt() ?? 0, + createdAt: DateTime.parse( + (map['created_at'] ?? map['updated_at'])! as String, + ), updatedAt: DateTime.parse(map['updated_at']! as String), ); } diff --git a/lib/models/supermarket.dart b/lib/models/supermarket.dart index 25dc87a..d11064e 100644 --- a/lib/models/supermarket.dart +++ b/lib/models/supermarket.dart @@ -9,12 +9,14 @@ class Supermarket { required this.name, required this.colorValue, this.sortOrder = 0, + this.bonusImagePath, }); final int? id; final String name; final int colorValue; final int sortOrder; + final String? bonusImagePath; Color get color => Color(colorValue); @@ -24,6 +26,7 @@ class Supermarket { name: map['name']! as String, colorValue: map['color']! as int, sortOrder: map['sort_order'] as int? ?? 0, + bonusImagePath: map['bonus_image_path'] as String?, ); } @@ -32,12 +35,17 @@ class Supermarket { String? name, int? colorValue, int? sortOrder, + String? bonusImagePath, + bool clearBonusImage = false, }) { return Supermarket( id: id ?? this.id, name: name ?? this.name, colorValue: colorValue ?? this.colorValue, sortOrder: sortOrder ?? this.sortOrder, + bonusImagePath: clearBonusImage + ? null + : (bonusImagePath ?? this.bonusImagePath), ); } } diff --git a/lib/product_detail_screen.dart b/lib/product_detail_screen.dart index 9ff6d58..acb264b 100644 --- a/lib/product_detail_screen.dart +++ b/lib/product_detail_screen.dart @@ -13,6 +13,7 @@ import 'utils/categories.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'utils/supermarkets.dart'; +import 'widgets/pick_shopping_list_sheet.dart'; import 'widgets/price_chart.dart'; import 'widgets/product_image.dart'; import 'widgets/store_tag.dart'; @@ -37,6 +38,7 @@ class _ProductDetailScreenState extends State { late final TextEditingController _notesController; final ImagePicker _picker = ImagePicker(); bool _ready = false; + bool _leaving = false; @override void initState() { @@ -48,7 +50,7 @@ class _ProductDetailScreenState extends State { @override void dispose() { - _saveNotes(); + _saveEdits(); _nameController.dispose(); _notesController.dispose(); super.dispose(); @@ -69,7 +71,18 @@ class _ProductDetailScreenState extends State { } void _reload() { - setState(() => _future = _load()); + setState(() { + _future = _load(); + }); + } + + Future _saveEdits() { + final name = _nameController.text.trim(); + return widget.repository.updateProduct( + id: widget.productId, + name: name.isEmpty ? null : name, + notes: _notesController.text.trim(), + ); } Future _saveName() async { @@ -86,6 +99,15 @@ class _ProductDetailScreenState extends State { ); } + Future _leave() async { + if (_leaving) return; + _leaving = true; + try { + await _saveEdits(); + } catch (_) {} + if (mounted) Navigator.of(context).pop(); + } + Future _toggleFavourite(Product product) async { await widget.repository.setFavourite(product.id, !product.favourite); if (mounted) _reload(); @@ -169,6 +191,22 @@ class _ProductDetailScreenState extends State { if (mounted) _reload(); } + Future _addToList(Product product) async { + final listId = await showPickShoppingListSheet( + context, + repository: widget.repository, + ); + if (listId == null || !mounted) return; + await widget.repository.addShoppingListItem( + listId: listId, + name: product.name, + productId: product.id, + ); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Added to shopping list'))); + } + Future _pickCategory(Product product) async { final used = await widget.repository.getUsedCategories(); if (!mounted) return; @@ -238,10 +276,8 @@ class _ProductDetailScreenState extends State { Future _openShop(int shopId) async { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ShopDetailScreen( - repository: widget.repository, - shopId: shopId, - ), + builder: (_) => + ShopDetailScreen(repository: widget.repository, shopId: shopId), ), ); if (mounted) _reload(); @@ -250,186 +286,206 @@ class _ProductDetailScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final stores = SettingsScope.maybeOf(context)?.supermarkets ?? - kDefaultSupermarkets; + final stores = + SettingsScope.maybeOf(context)?.supermarkets ?? kDefaultSupermarkets; - return Scaffold( - appBar: AppBar(title: const Text('Product')), - body: FutureBuilder( - future: _future, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text( - 'Could not load this product.', - style: theme.textTheme.bodyLarge, - ), - ); - } - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _leave(); + }, + child: Scaffold( + appBar: AppBar(title: const Text('Product')), + body: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + 'Could not load this product.', + style: theme.textTheme.bodyLarge, + ), + ); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } - final product = snapshot.data!.product; - final purchases = snapshot.data!.purchases; + final product = snapshot.data!.product; + final purchases = snapshot.data!.purchases; - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: _chooseImageSource, - child: Stack( - children: [ - ProductImage( - name: product.name, - path: product.imagePath, - size: 88, - ), - Positioned( - right: 0, - bottom: 0, - child: Material( - color: theme.colorScheme.primary, - shape: const CircleBorder(), - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.camera_alt, - size: 14, - color: theme.colorScheme.onPrimary, - ), - ), + return ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: _chooseImageSource, + child: Stack( + children: [ + ProductImage( + name: product.name, + path: product.imagePath, + size: 88, ), - ), - ], - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: TextField( - controller: _nameController, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - labelText: 'Name', - isDense: true, + Positioned( + right: 0, + bottom: 0, + child: Material( + color: theme.colorScheme.primary, + shape: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.camera_alt, + size: 14, + color: theme.colorScheme.onPrimary, ), - onSubmitted: (_) => _saveName(), - onEditingComplete: _saveName, ), ), - IconButton( - tooltip: product.favourite - ? 'Remove favourite' - : 'Add favourite', - onPressed: () => _toggleFavourite(product), - icon: Icon( - product.favourite - ? Icons.star - : Icons.star_outline, - color: product.favourite - ? theme.colorScheme.primary - : null, - ), - ), - ], - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: InkWell( - onTap: _pickStore, - borderRadius: BorderRadius.circular(8), - child: product.supermarket == null - ? Text( - 'Add supermarket', - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.primary, - ), - ) - : StoreTag( - name: product.supermarket, - stores: stores, - ), ), - ), - ], + ], + ), ), - ), - ], - ), - const SizedBox(height: 24), - Text( - formatEuro(product.lastPrice), - style: theme.textTheme.headlineMedium, - ), - Text('Price per unit', style: theme.textTheme.bodyMedium), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerLeft, - child: ActionChip( - avatar: const Icon(Icons.category_outlined, size: 18), - label: Text(product.category ?? 'Add category'), - onPressed: () => _pickCategory(product), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _nameController, + textCapitalization: + TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Name', + isDense: true, + ), + onSubmitted: (_) => _saveName(), + onEditingComplete: _saveName, + onTapOutside: (_) => _saveName(), + ), + ), + IconButton( + tooltip: product.favourite + ? 'Remove favourite' + : 'Add favourite', + onPressed: () => _toggleFavourite(product), + icon: Icon( + product.favourite + ? Icons.star + : Icons.star_outline, + color: product.favourite + ? theme.colorScheme.primary + : null, + ), + ), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: InkWell( + onTap: _pickStore, + borderRadius: BorderRadius.circular(8), + child: product.supermarket == null + ? Text( + 'Add supermarket', + style: theme.textTheme.labelMedium + ?.copyWith( + color: theme.colorScheme.primary, + ), + ) + : StoreTag( + name: product.supermarket, + stores: stores, + ), + ), + ), + ], + ), + ), + ], ), - ), - const SizedBox(height: 24), - Text( - 'Price and purchase history', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - if (purchases.isEmpty) + const SizedBox(height: 24), Text( - 'Scan a receipt to start a history.', - style: theme.textTheme.bodyMedium, - ) - else ...[ - PriceChart(purchases: purchases), - const SizedBox(height: 8), - ...purchases.map((purchase) { - return ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - leading: const Icon(Icons.shopping_bag_outlined), - title: Text(formatEuro(purchase.unitOrLinePrice)), - subtitle: Text( - [ - formatDate(purchase.boughtAt), - purchase.supermarket, - if (purchase.quantity != null) '${purchase.quantity}×', - ].join(' · '), - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openShop(purchase.shopId), - ); - }), - ], - const SizedBox(height: 24), - Text('Notes', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - TextField( - controller: _notesController, - minLines: 3, - maxLines: 6, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - hintText: 'Add a note about this product', + formatEuro(product.lastPrice), + style: theme.textTheme.headlineMedium, ), - onEditingComplete: _saveNotes, - onTapOutside: (_) => _saveNotes(), - ), - ], - ); - }, + Text('Price per unit', style: theme.textTheme.bodyMedium), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: ActionChip( + avatar: const Icon(Icons.category_outlined, size: 18), + label: Text(product.category ?? 'Add category'), + onPressed: () => _pickCategory(product), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + onPressed: () => _addToList(product), + icon: const Icon(Icons.playlist_add), + label: const Text('Add to list'), + ), + ), + const SizedBox(height: 24), + Text( + 'Price and purchase history', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (purchases.isEmpty) + Text( + 'Scan a receipt to start a history.', + style: theme.textTheme.bodyMedium, + ) + else ...[ + PriceChart(purchases: purchases), + const SizedBox(height: 8), + ...purchases.map((purchase) { + return ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: const Icon(Icons.shopping_bag_outlined), + title: Text(formatEuro(purchase.unitOrLinePrice)), + subtitle: Text( + [ + formatDate(purchase.boughtAt), + purchase.supermarket, + if (purchase.quantity != null) + '${purchase.quantity}×', + ].join(' · '), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openShop(purchase.shopId), + ); + }), + ], + const SizedBox(height: 24), + Text('Notes', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + TextField( + controller: _notesController, + minLines: 3, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + hintText: 'Add a note about this product', + ), + onEditingComplete: _saveNotes, + onTapOutside: (_) => _saveNotes(), + ), + ], + ); + }, + ), ), ); } @@ -477,10 +533,7 @@ class _CategoryNameDialogState extends State<_CategoryNameDialog> { onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), - FilledButton( - onPressed: _save, - child: const Text('Save'), - ), + FilledButton(onPressed: _save, child: const Text('Save')), ], ); } diff --git a/lib/products_screen.dart b/lib/products_screen.dart index 53372d4..f7eacf8 100644 --- a/lib/products_screen.dart +++ b/lib/products_screen.dart @@ -10,8 +10,10 @@ import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'utils/fuzzy.dart'; import 'utils/money.dart'; +import 'utils/product_names.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/product_image.dart'; +import 'widgets/product_name_conflicts.dart'; import 'widgets/profile_button.dart'; import 'widgets/settings_button.dart'; import 'widgets/store_tag.dart'; @@ -27,11 +29,14 @@ class ProductsScreen extends StatefulWidget { class _ProductsScreenState extends State { ProductSort _sort = ProductSort.preferred; - bool _favouritesOnly = false; bool _searching = false; bool _editing = false; final TextEditingController _search = TextEditingController(); List? _products; + ProductNameIssues _nameIssues = const ProductNameIssues( + mergeGroups: [], + spellingVariants: [], + ); Object? _error; int _loadToken = 0; @@ -55,13 +60,12 @@ class _ProductsScreenState extends State { Future _load() async { final token = ++_loadToken; try { - final products = await widget.repository.getAll( - sort: _sort, - favouritesOnly: _favouritesOnly, - ); + final products = await widget.repository.getAll(sort: _sort); + final nameIssues = await widget.repository.getProductNameIssues(); if (!mounted || token != _loadToken) return; setState(() { _products = products; + _nameIssues = nameIssues; _error = null; if (products.isEmpty) { _editing = false; @@ -139,8 +143,7 @@ class _ProductsScreenState extends State { final confirmed = await confirmAction( context, title: 'Delete all products?', - message: - 'This removes every product from your catalog. Shopping trips stay in the shop log.', + message: 'This removes every product from your catalog. Shopping trips stay in the shop log.', ); if (!confirmed || !mounted) return; setState(() { @@ -156,28 +159,31 @@ class _ProductsScreenState extends State { } } + Set get _issueProductIds => _nameIssues.productIds; + + Future _resolveNameIssues({int? focusProductId}) async { + final issues = await widget.repository.getProductNameIssues(); + if (!mounted || issues.isEmpty) return; + final resolved = await showProductNameConflictSheet( + context, + repository: widget.repository, + issues: issues, + focusProductId: focusProductId, + ); + if (resolved && 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 (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); + if (_sort == ProductSort.start && mounted) _load(); } catch (_) { if (mounted) _load(); } @@ -187,7 +193,9 @@ class _ProductsScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; - final products = _products == null ? const [] : _filter(_products!); + final products = _products == null + ? const [] + : _filter(_products!); return Scaffold( appBar: AppBar( @@ -218,6 +226,12 @@ class _ProductsScreenState extends State { : null, icon: Icon(_searching ? Icons.close : Icons.search), ), + if (_hasProducts && _nameIssues.isNotEmpty && !_editing) + IconButton( + tooltip: 'Resolve name issues', + onPressed: _resolveNameIssues, + icon: const Icon(kProductNameConflictIcon), + ), if (_hasProducts) IconButton( tooltip: _editing ? 'Done' : 'Edit', @@ -243,46 +257,35 @@ class _ProductsScreenState extends State { 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; - }); - _load(); - }, - ), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: ProductSort.name, + label: Text('A–Z'), ), - const SizedBox(width: 8), - IconButton.filledTonal( - tooltip: 'Favourites', - isSelected: _favouritesOnly, - onPressed: () { - setState(() { - _favouritesOnly = !_favouritesOnly; - }); - _load(); - }, - icon: Icon( - _favouritesOnly ? Icons.star : Icons.star_outline, - ), + ButtonSegment( + value: ProductSort.preferred, + label: Text('Preferred'), + ), + ButtonSegment( + value: ProductSort.start, + label: Text('Start'), ), ], + selected: {_sort}, + onSelectionChanged: (selected) { + setState(() { + _sort = selected.first; + }); + _load(); + }, ), ), + if (_nameIssues.isNotEmpty && !_editing) + ProductNameConflictBanner( + issues: _nameIssues, + onTap: _resolveNameIssues, + ), Expanded(child: _buildList(theme, stores, products)), ], ), @@ -308,9 +311,7 @@ class _ProductsScreenState extends State { if (_products!.isEmpty) { return Center( child: Text( - _favouritesOnly - ? 'Star products to keep them as favourites.' - : 'Confirmed receipt items will show up here.', + 'Confirmed receipt items will show up here.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), @@ -332,6 +333,7 @@ class _ProductsScreenState extends State { separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final product = products[index]; + final hasIssue = _issueProductIds.contains(product.id); return ListTile( dense: true, visualDensity: VisualDensity.compact, @@ -372,10 +374,25 @@ class _ProductsScreenState extends State { ), ], ) - : ProductImage( - name: product.name, - path: product.imagePath, - size: 36, + : Stack( + clipBehavior: Clip.none, + children: [ + ProductImage( + name: product.name, + path: product.imagePath, + size: 36, + ), + if (hasIssue && !_editing) + Positioned( + right: -2, + top: -2, + child: ProductNameConflictMarker( + compact: true, + onTap: () => + _resolveNameIssues(focusProductId: product.id), + ), + ), + ], ), title: Text( product.name, @@ -395,9 +412,21 @@ class _ProductsScreenState extends State { ), ), ), - trailing: Text( - formatEuro(product.lastPrice), - style: theme.textTheme.titleMedium, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasIssue && !_editing) + Padding( + padding: const EdgeInsets.only(right: 4), + child: ProductNameConflictMarker( + onTap: () => _resolveNameIssues(focusProductId: product.id), + ), + ), + Text( + formatEuro(product.lastPrice), + style: theme.textTheme.titleMedium, + ), + ], ), onTap: _editing ? null : () => _openProduct(product), ); diff --git a/lib/review_screen.dart b/lib/review_screen.dart index f376f45..c0f7cc5 100644 --- a/lib/review_screen.dart +++ b/lib/review_screen.dart @@ -9,6 +9,7 @@ import 'services/product_repository.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'utils/supermarkets.dart'; +import 'widgets/discounted_price.dart'; import 'widgets/item_edit_sheet.dart'; import 'widgets/store_tag.dart'; import 'widgets/tag_editor.dart'; @@ -302,10 +303,7 @@ class _ReviewScreenState extends State { child: ListTile( title: Text(item.name), subtitle: _itemSubtitle(item), - trailing: Text( - formatEuro(item.price), - style: theme.textTheme.titleMedium, - ), + trailing: DiscountedPrice(item: item), onTap: _saving ? null : () => _edit(existing: item), ), ); @@ -356,12 +354,7 @@ class _ReviewScreenState extends State { } Widget? _itemSubtitle(LineItem item) { - final parts = [ - if (item.quantity != null) - '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', - if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}', - ]; - if (parts.isEmpty) return null; - return Text(parts.join(' · ')); + if (item.quantity == null) return null; + return Text('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}'); } } diff --git a/lib/services/app_settings.dart b/lib/services/app_settings.dart index 8a8f186..c5513fb 100644 --- a/lib/services/app_settings.dart +++ b/lib/services/app_settings.dart @@ -84,6 +84,8 @@ class SettingsScope extends InheritedNotifier { } static AppSettings? maybeOf(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType()?.notifier; + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier; } } diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index fb0ac65..5dd4b1a 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -126,6 +126,7 @@ class BackupService { if (copy['normalized_name'] == null && copy['name'] is String) { copy['normalized_name'] = ProductRepository.normalizeName( copy['name']! as String, + supermarket: copy['supermarket'] as String?, ); } products.add(copy); diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index 1a789ce..4899335 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -1,6 +1,7 @@ /// Local SQLite store of products and shopping trips. library; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -16,6 +17,7 @@ import '../models/shop.dart'; import '../models/shopping_list.dart'; import '../models/supermarket.dart'; import '../utils/money.dart'; +import '../utils/product_names.dart'; import '../utils/supermarkets.dart'; class ProductRepository extends ChangeNotifier { @@ -35,7 +37,7 @@ class ProductRepository extends ChangeNotifier { p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); return openDatabase( path, - version: 7, + version: 9, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys = ON'); }, @@ -72,6 +74,14 @@ class ProductRepository extends ChangeNotifier { if (oldVersion < 7) { await _upgradeShopItemsProductFk(db); } + if (oldVersion < 8) { + await db.execute( + 'ALTER TABLE supermarkets ADD COLUMN bonus_image_path TEXT', + ); + } + if (oldVersion < 9) { + await _upgradeProductNormalizedNamesV9(db); + } }, ); } @@ -107,7 +117,8 @@ class ProductRepository extends ChangeNotifier { id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, color INTEGER NOT NULL, - sort_order INTEGER NOT NULL + sort_order INTEGER NOT NULL, + bonus_image_path TEXT ) '''); } @@ -222,8 +233,28 @@ class ProductRepository extends ChangeNotifier { '''); } - static String normalizeName(String name) => - name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); + static String normalizeName(String name, {String? supermarket}) => + productConflictKey(name: name, supermarket: supermarket); + + Future _upgradeProductNormalizedNamesV9(DatabaseExecutor db) async { + final rows = await db.query('products', columns: ['id', 'name', 'supermarket']); + final used = {}; + for (final row in rows) { + final id = row['id']! as int; + final name = row['name']! as String; + final supermarket = row['supermarket'] as String?; + var key = normalizeName(name, supermarket: supermarket); + if (!used.add(key)) { + key = '$key#$id'; + } + await db.update( + 'products', + {'normalized_name': key}, + where: 'id = ?', + whereArgs: [id], + ); + } + } Future _persistImage(String sourcePath, String relativePath) async { final dir = await getApplicationDocumentsDirectory(); @@ -308,7 +339,7 @@ class ProductRepository extends ChangeNotifier { required String supermarket, required String now, }) async { - final key = normalizeName(item.name); + final key = normalizeName(item.name, supermarket: supermarket); final existing = await txn.query( 'products', where: 'normalized_name = ?', @@ -359,12 +390,12 @@ class ProductRepository extends ChangeNotifier { Future> getAll({ ProductSort sort = ProductSort.name, - bool favouritesOnly = false, }) async { final db = await _database; final orderBy = switch (sort) { ProductSort.name => 'name COLLATE NOCASE ASC', ProductSort.preferred => 'times_seen DESC, name COLLATE NOCASE ASC', + ProductSort.start => 'favourite DESC, name COLLATE NOCASE ASC', }; final rows = await db.rawQuery(''' SELECT @@ -378,12 +409,132 @@ class ProductRepository extends ChangeNotifier { LIMIT 1 ) AS display_price FROM products - ${favouritesOnly ? 'WHERE favourite = 1' : ''} ORDER BY $orderBy '''); return rows.map(Product.fromMap).toList(); } + Future> + _shopLineNames() async { + final db = await _database; + final rows = await db.rawQuery(''' + SELECT shop_items.product_id, shop_items.name, shops.supermarket + FROM shop_items + JOIN shops ON shops.id = shop_items.shop_id + WHERE shop_items.product_id IS NOT NULL + '''); + return [ + for (final row in rows) + if (_readProductId(row['product_id']) case final int id + when row['name'] is String && row['supermarket'] is String) + ( + productId: id, + name: row['name']! as String, + supermarket: row['supermarket']! as String, + ), + ]; + } + + int? _readProductId(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return null; + } + + Future getProductNameIssues() async { + final products = await getAll(); + return buildProductNameIssues( + products: products, + shopLines: await _shopLineNames(), + dismissedKeys: await getDismissedNameIssues(), + ); + } + + Future> getDismissedNameIssues() async { + final db = await _database; + final rows = await db.query( + 'app_settings', + where: 'key = ?', + whereArgs: ['dismissed_name_issues'], + limit: 1, + ); + if (rows.isEmpty) return {}; + final raw = rows.first['value'] as String?; + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw) as List; + return decoded.map((entry) => entry as String).toSet(); + } catch (_) { + return {}; + } + } + + Future dismissNameIssue(String key) async { + final dismissed = await getDismissedNameIssues() + ..add(key); + final db = await _database; + await db.insert('app_settings', { + 'key': 'dismissed_name_issues', + 'value': jsonEncode(dismissed.toList()..sort()), + }, conflictAlgorithm: ConflictAlgorithm.replace); + notifyListeners(); + } + + Future>> getNameConflictGroups() async { + return [ + for (final group in (await getProductNameIssues()).mergeGroups) + group.products, + ]; + } + + Future> getProductSpellingVariants() async { + return (await getProductNameIssues()).spellingVariants; + } + + /// Picks one spelling for a catalog product at a store. + Future unifyProductSpelling({ + required int productId, + required String name, + String? supermarket, + }) async { + final trimmed = name.trim(); + final key = normalizeName(trimmed, supermarket: supermarket); + final db = await _database; + final now = DateTime.now().toIso8601String(); + final storeKey = supermarket == null + ? null + : normalizeSupermarket(supermarket); + + await db.transaction((txn) async { + await txn.update( + 'products', + {'name': trimmed, 'normalized_name': key, 'updated_at': now}, + where: 'id = ?', + whereArgs: [productId], + ); + final rows = await txn.rawQuery(''' + SELECT shop_items.id, shop_items.name + FROM shop_items + JOIN shops ON shops.id = shop_items.shop_id + WHERE shop_items.product_id = ? + ${storeKey == null ? '' : 'AND upper(trim(shops.supermarket)) = ?'} + ''', storeKey == null ? [productId] : [productId, storeKey]); + for (final row in rows) { + final lineName = row['name'] as String?; + final lineId = row['id']; + if (lineName == null || lineId == null) continue; + if (normalizeName(lineName, supermarket: supermarket) != key) continue; + await txn.update( + 'shop_items', + {'name': trimmed}, + where: 'id = ?', + whereArgs: [lineId], + ); + } + }); + notifyListeners(); + } + Future getOverview() async { final db = await _database; final productCount = @@ -399,7 +550,9 @@ class ProductRepository extends ChangeNotifier { ) ?? 0; final tripCount = - Sqflite.firstIntValue(await db.rawQuery('SELECT COUNT(*) FROM shops')) ?? + Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM shops'), + ) ?? 0; final listCount = Sqflite.firstIntValue( @@ -480,13 +633,28 @@ class ProductRepository extends ChangeNotifier { bool clearCategory = false, }) async { final db = await _database; + final existing = await db.query( + 'products', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (existing.isEmpty) return; + final row = existing.first; + + final nextName = name?.trim() ?? row['name']! as String; + final nextSupermarket = clearSupermarket + ? null + : (supermarket ?? row['supermarket'] as String?); + final values = { 'updated_at': DateTime.now().toIso8601String(), + 'normalized_name': normalizeName( + nextName, + supermarket: nextSupermarket, + ), }; - if (name != null) { - values['name'] = name.trim(); - values['normalized_name'] = normalizeName(name); - } + if (name != null) values['name'] = nextName; if (favourite != null) values['favourite'] = favourite ? 1 : 0; if (clearSupermarket) { values['supermarket'] = null; @@ -565,6 +733,145 @@ class ProductRepository extends ChangeNotifier { notifyListeners(); } + /// Moves trip lines and list items onto [keepId], then deletes the rest. + Future mergeProducts({ + required int keepId, + required List sourceIds, + }) async { + final mergeIds = [ + for (final id in sourceIds) + if (id != keepId) id, + ]; + if (mergeIds.isEmpty) return; + + final db = await _database; + final keepRows = await db.query( + 'products', + where: 'id = ?', + whereArgs: [keepId], + limit: 1, + ); + if (keepRows.isEmpty) return; + final keep = Product.fromMap(keepRows.first); + final losers = []; + for (final id in mergeIds) { + final rows = await db.query( + 'products', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isNotEmpty) losers.add(Product.fromMap(rows.first)); + } + if (losers.isEmpty) return; + + var imagePath = keep.imagePath; + final unusedImages = []; + for (final loser in losers) { + final path = loser.imagePath; + if ((imagePath == null || imagePath.isEmpty) && + path != null && + path.isNotEmpty) { + imagePath = path; + } else { + unusedImages.add(path); + } + } + + var notes = keep.notes?.trim(); + for (final loser in losers) { + final extra = loser.notes?.trim(); + if (extra == null || extra.isEmpty) continue; + if (notes == null || notes.isEmpty) { + notes = extra; + } else if (notes != extra) { + notes = '$notes\n$extra'; + } + } + + final favourite = keep.favourite || losers.any((item) => item.favourite); + final supermarket = (keep.supermarket == null || keep.supermarket!.isEmpty) + ? losers + .map((item) => item.supermarket) + .firstWhere( + (value) => value != null && value.isNotEmpty, + orElse: () => keep.supermarket, + ) + : keep.supermarket; + final category = (keep.category == null || keep.category!.isEmpty) + ? losers + .map((item) => item.category) + .firstWhere( + (value) => value != null && value.isNotEmpty, + orElse: () => keep.category, + ) + : keep.category; + + await db.transaction((txn) async { + for (final id in mergeIds) { + await txn.update( + 'shop_items', + {'product_id': keepId}, + where: 'product_id = ?', + whereArgs: [id], + ); + await txn.update( + 'shopping_list_items', + {'product_id': keepId}, + where: 'product_id = ?', + whereArgs: [id], + ); + await txn.delete('products', where: 'id = ?', whereArgs: [id]); + } + + final seen = Sqflite.firstIntValue( + await txn.rawQuery( + 'SELECT COUNT(*) FROM shop_items WHERE product_id = ?', + [keepId], + ), + ); + final priceRows = await txn.rawQuery( + ''' + SELECT COALESCE(shop_items.unit_price, shop_items.price) AS price + FROM shop_items + JOIN shops ON shops.id = shop_items.shop_id + WHERE shop_items.product_id = ? + ORDER BY shops.shopped_at DESC, shop_items.id DESC + LIMIT 1 + ''', + [keepId], + ); + final lastPrice = priceRows.isEmpty + ? keep.lastPrice + : (priceRows.first['price'] as num).toDouble(); + + await txn.update( + 'products', + { + 'normalized_name': normalizeName( + keep.name, + supermarket: keep.supermarket, + ), + 'times_seen': seen ?? keep.timesSeen, + 'last_price': lastPrice, + 'favourite': favourite ? 1 : 0, + 'image_path': imagePath, + 'supermarket': supermarket, + 'notes': (notes == null || notes.isEmpty) ? null : notes, + 'category': category, + 'updated_at': DateTime.now().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [keepId], + ); + }); + + for (final path in unusedImages) { + await _tryDeleteFile(path); + } + notifyListeners(); + } + Future _tryDeleteFile(String? path) async { if (path == null || path.isEmpty) return; try { @@ -691,17 +998,20 @@ class ProductRepository extends ChangeNotifier { required int id, String? name, bool clearName = false, + DateTime? shoppedAt, List? tags, }) async { final db = await _database; await db.transaction((txn) async { + final values = {}; if (clearName || name != null) { - await txn.update( - 'shops', - {'name': clearName ? null : _optionalText(name)}, - where: 'id = ?', - whereArgs: [id], - ); + values['name'] = clearName ? null : _optionalText(name); + } + if (shoppedAt != null) { + values['shopped_at'] = shoppedAt.toIso8601String(); + } + if (values.isNotEmpty) { + await txn.update('shops', values, where: 'id = ?', whereArgs: [id]); } if (tags != null) { await _replaceShopTags(txn, shopId: id, tags: tags); @@ -760,6 +1070,51 @@ class ProductRepository extends ChangeNotifier { notifyListeners(); } + /// Adds a line to an existing trip and upserts the product catalog entry. + Future addShopItem({ + required int shopId, + required LineItem item, + }) async { + final db = await _database; + final shopRows = await db.query( + 'shops', + where: 'id = ?', + whereArgs: [shopId], + limit: 1, + ); + if (shopRows.isEmpty) throw StateError('Shop not found'); + final supermarket = shopRows.first['supermarket']! as String; + final now = DateTime.now().toIso8601String(); + + final itemId = await db.transaction((txn) async { + final productId = await _upsertProduct( + txn, + item: item, + supermarket: supermarket, + now: now, + ); + return txn.insert('shop_items', { + 'shop_id': shopId, + 'product_id': productId, + 'name': item.name.trim(), + 'price': item.price, + 'quantity': item.quantity, + 'unit_price': item.unitPrice, + }); + }); + + await _recalculateShopTotal(shopId); + notifyListeners(); + + final rows = await db.query( + 'shop_items', + where: 'id = ?', + whereArgs: [itemId], + limit: 1, + ); + return ShopItem.fromMap(rows.first); + } + /// Removes one trip line. Catalog products are left unchanged. Future deleteShopItem(int id) async { final db = await _database; @@ -825,6 +1180,7 @@ class ProductRepository extends ChangeNotifier { SELECT shopping_lists.id, shopping_lists.name, + shopping_lists.created_at, shopping_lists.updated_at, COUNT(shopping_list_items.id) AS item_count, COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count @@ -844,6 +1200,7 @@ class ProductRepository extends ChangeNotifier { SELECT shopping_lists.id, shopping_lists.name, + shopping_lists.created_at, shopping_lists.updated_at, COUNT(shopping_list_items.id) AS item_count, COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count @@ -882,6 +1239,18 @@ class ProductRepository extends ChangeNotifier { notifyListeners(); } + Future resetShoppingListChecks(int listId) async { + final db = await _database; + await db.update( + 'shopping_list_items', + {'checked': 0}, + where: 'list_id = ? AND checked = 1', + whereArgs: [listId], + ); + await _touchShoppingList(listId); + notifyListeners(); + } + Future deleteShoppingList(int id) async { final db = await _database; await db.transaction((txn) async { @@ -911,23 +1280,72 @@ class ProductRepository extends ChangeNotifier { required String name, int? productId, }) async { - final db = await _database; - final max = Sqflite.firstIntValue( - await db.rawQuery( - 'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?', - [listId], - ), + final added = await addShoppingListItems( + listId: listId, + items: [(name: name, productId: productId)], ); - final id = await db.insert('shopping_list_items', { - 'list_id': listId, - 'product_id': productId, - 'name': name.trim(), - 'checked': 0, - 'sort_order': (max ?? -1) + 1, + return added.single.id; + } + + Future> addShoppingListItems({ + required int listId, + required List<({String name, int? productId})> items, + }) async { + if (items.isEmpty) return const []; + + final db = await _database; + final added = []; + await db.transaction((txn) async { + var max = Sqflite.firstIntValue( + await txn.rawQuery( + 'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?', + [listId], + ), + ); + var sortOrder = (max ?? -1) + 1; + for (final item in items) { + final name = item.name.trim(); + if (name.isEmpty) continue; + final id = await txn.insert('shopping_list_items', { + 'list_id': listId, + 'product_id': item.productId, + 'name': name, + 'checked': 0, + 'sort_order': sortOrder++, + }); + added.add( + ShoppingListItem( + id: id, + listId: listId, + name: name, + checked: false, + sortOrder: sortOrder - 1, + productId: item.productId, + ), + ); + } + await txn.update( + 'shopping_lists', + {'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [listId], + ); }); - await _touchShoppingList(listId); notifyListeners(); - return id; + return added; + } + + Future setSupermarketBonusCard(int id, String sourcePath) async { + final stored = await _persistImage(sourcePath, 'bonus_cards/store_$id.jpg'); + final db = await _database; + await db.update( + 'supermarkets', + {'bonus_image_path': stored}, + where: 'id = ?', + whereArgs: [id], + ); + notifyListeners(); + return stored; } Future updateShoppingListItem({ @@ -1057,6 +1475,7 @@ class ProductRepository extends ChangeNotifier { 'name': store.name.trim(), 'color': store.colorValue, 'sort_order': store.sortOrder, + 'bonus_image_path': store.bonusImagePath, }, where: 'id = ?', whereArgs: [id], @@ -1166,7 +1585,13 @@ class ProductRepository extends ChangeNotifier { notifyListeners(); } - static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'}; + static const _supermarketColumns = { + 'id', + 'name', + 'color', + 'sort_order', + 'bonus_image_path', + }; static const _productColumns = { 'id', 'name', diff --git a/lib/shop_detail_screen.dart b/lib/shop_detail_screen.dart index 7f8c072..2c3438c 100644 --- a/lib/shop_detail_screen.dart +++ b/lib/shop_detail_screen.dart @@ -31,15 +31,18 @@ class ShopDetailScreen extends StatefulWidget { } class _ShopDetailScreenState extends State { - late Future<({Shop shop, List items})> _future; + Shop? _shop; + List? _items; + Object? _error; bool _editing = false; List _tagSuggestions = []; + int _loadToken = 0; @override void initState() { super.initState(); - _future = _load(); - widget.repository.addListener(_reload); + widget.repository.addListener(_load); + _load(); widget.repository .getUsedShopTags() .then((tags) { @@ -50,22 +53,26 @@ class _ShopDetailScreenState extends State { @override void dispose() { - widget.repository.removeListener(_reload); + widget.repository.removeListener(_load); super.dispose(); } - Future<({Shop shop, List items})> _load() async { - final shop = await widget.repository.getShop(widget.shopId); - if (shop == null) { - throw StateError('Shop not found'); + Future _load() async { + final token = ++_loadToken; + try { + final shop = await widget.repository.getShop(widget.shopId); + if (shop == null) throw StateError('Shop not found'); + final items = await widget.repository.getShopItems(widget.shopId); + if (!mounted || token != _loadToken) return; + setState(() { + _shop = shop; + _items = items; + _error = null; + }); + } catch (error) { + if (!mounted || token != _loadToken) return; + setState(() => _error = error); } - final items = await widget.repository.getShopItems(widget.shopId); - return (shop: shop, items: items); - } - - void _reload() { - if (!mounted) return; - setState(() => _future = _load()); } Future _openReceipt(File file) async { @@ -93,6 +100,56 @@ class _ShopDetailScreenState extends State { ); } + Future _addItem() async { + final shop = _shop; + if (shop == null) return; + final result = await showItemEditSheet(context); + if (result == null || !mounted) return; + + final pendingId = -DateTime.now().microsecondsSinceEpoch; + final pending = ShopItem( + id: pendingId, + shopId: shop.id, + name: result.name, + price: result.price, + quantity: result.quantity, + unitPrice: result.unitPrice, + ); + setState(() { + _items = [...?_items, pending]; + _shop = Shop( + id: shop.id, + supermarket: shop.supermarket, + shoppedAt: shop.shoppedAt, + total: roundMoney(shop.total + result.price), + itemCount: shop.itemCount + 1, + name: shop.name, + receiptImagePath: shop.receiptImagePath, + tags: shop.tags, + ); + }); + + try { + final saved = await widget.repository.addShopItem( + shopId: shop.id, + item: result, + ); + if (!mounted) return; + setState(() { + _items = [ + for (final entry in _items ?? const []) + if (entry.id == pendingId) saved else entry, + ]; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not add this product.')), + ); + _load(); + } + } + Future _editItem(ShopItem item) async { final result = await showItemEditSheet( context, @@ -112,7 +169,7 @@ class _ShopDetailScreenState extends State { quantity: result.quantity, unitPrice: result.unitPrice, ); - if (mounted) _reload(); + if (mounted) _load(); } Future _removeItem(ShopItem item) async { @@ -124,8 +181,59 @@ class _ShopDetailScreenState extends State { confirmLabel: 'Remove', ); if (!confirmed || !mounted) return; - await widget.repository.deleteShopItem(item.id); - if (mounted) _reload(); + + final isLastItem = (_items?.length ?? 0) == 1; + final shop = _shop; + + setState(() { + _items = [ + for (final entry in _items ?? const []) + if (entry.id != item.id) entry, + ]; + if (shop != null) { + _shop = Shop( + id: shop.id, + supermarket: shop.supermarket, + shoppedAt: shop.shoppedAt, + total: roundMoney(shop.total - item.price), + itemCount: shop.itemCount - 1, + name: shop.name, + receiptImagePath: shop.receiptImagePath, + tags: shop.tags, + ); + } + }); + try { + await widget.repository.deleteShopItem(item.id); + if (!mounted || !isLastItem || shop == null) return; + await _confirmRemoveEmptyTrip(shop); + } catch (_) { + if (mounted) _load(); + } + } + + Future _confirmRemoveEmptyTrip(Shop shop) async { + final removeTrip = await confirmAction( + context, + title: 'Remove trip?', + message: + 'This trip has no products left. Remove it from your shop log? ' + 'Products from this trip stay in your catalog.', + confirmLabel: 'Remove trip', + ); + if (!mounted) return; + if (!removeTrip) return; + + try { + await widget.repository.deleteShop(shop.id); + if (mounted) Navigator.of(context).pop(); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not remove this trip.')), + ); + _load(); + } } Future _editName(Shop shop) async { @@ -142,12 +250,27 @@ class _ShopDetailScreenState extends State { name: name, clearName: name.isEmpty, ); - if (mounted) _reload(); + if (mounted) _load(); + } + + Future _editDate(Shop shop) async { + final picked = await showDatePicker( + context: context, + initialDate: shop.shoppedAt, + firstDate: DateTime(2018), + lastDate: DateTime.now().add(const Duration(days: 1)), + ); + if (picked == null || !mounted) return; + await widget.repository.updateShop( + id: shop.id, + shoppedAt: DateTime(picked.year, picked.month, picked.day), + ); + if (mounted) _load(); } Future _setTags(Shop shop, List tags) async { await widget.repository.updateShop(id: shop.id, tags: tags); - if (mounted) _reload(); + if (mounted) _load(); } Widget? _itemSubtitle(ShopItem item) { @@ -175,10 +298,16 @@ class _ShopDetailScreenState extends State { ), ], ), - body: FutureBuilder( - future: _future, - builder: (context, snapshot) { - if (snapshot.hasError) { + floatingActionButton: _editing + ? FloatingActionButton.extended( + onPressed: _addItem, + icon: const Icon(Icons.add), + label: const Text('Add product'), + ) + : null, + body: Builder( + builder: (context) { + if (_error != null) { return Center( child: Text( 'Could not load this trip.', @@ -186,18 +315,18 @@ class _ShopDetailScreenState extends State { ), ); } - if (!snapshot.hasData) { + final shop = _shop; + final items = _items; + if (shop == null || items == null) { return const Center(child: CircularProgressIndicator()); } - final shop = snapshot.data!.shop; - final items = snapshot.data!.items; final receipt = shop.receiptImagePath; final receiptFile = receipt == null ? null : File(receipt); final hasReceipt = receiptFile != null && receiptFile.existsSync(); return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), children: [ if (_editing) ListTile( @@ -218,10 +347,19 @@ class _ShopDetailScreenState extends State { Text(shop.supermarket, style: theme.textTheme.titleMedium), ], const SizedBox(height: 4), - Text( - formatDate(shop.shoppedAt), - style: theme.textTheme.bodyLarge, - ), + if (_editing) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(formatDate(shop.shoppedAt)), + subtitle: const Text('Trip date'), + trailing: const Icon(Icons.calendar_today_outlined), + onTap: () => _editDate(shop), + ) + else + Text( + formatDate(shop.shoppedAt), + style: theme.textTheme.bodyLarge, + ), if (_editing) ...[ const SizedBox(height: 16), TagEditor( diff --git a/lib/shop_log_screen.dart b/lib/shop_log_screen.dart index 2a005e4..5b5d71d 100644 --- a/lib/shop_log_screen.dart +++ b/lib/shop_log_screen.dart @@ -95,12 +95,14 @@ class _ShopLogScreenState extends State { 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; setState(() { - _shops = [for (final item in _shops ?? const []) if (item.id != shop.id) item]; + _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; @@ -306,10 +308,7 @@ class _ShopLogBody extends StatelessWidget { onDeleted: () => onSelectDay(null), ), if (tagFilter != null) - InputChip( - label: Text(tagFilter!), - onDeleted: onClearTag, - ), + InputChip(label: Text(tagFilter!), onDeleted: onClearTag), ], ), ), diff --git a/lib/shopping_list_detail_screen.dart b/lib/shopping_list_detail_screen.dart index 521b654..c98383d 100644 --- a/lib/shopping_list_detail_screen.dart +++ b/lib/shopping_list_detail_screen.dart @@ -3,10 +3,11 @@ library; import 'package:flutter/material.dart'; -import 'models/product.dart'; import 'models/shopping_list.dart'; import 'product_detail_screen.dart'; import 'services/product_repository.dart'; +import 'utils/dates.dart'; +import 'widgets/add_list_items_sheet.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/name_dialog.dart'; @@ -26,34 +27,41 @@ class ShoppingListDetailScreen extends StatefulWidget { } class _ShoppingListDetailScreenState extends State { - late Future<({ShoppingList list, List items})> _future; + ShoppingList? _list; + List? _items; + Object? _error; bool _editing = false; + int _loadToken = 0; @override void initState() { super.initState(); - _future = _load(); - widget.repository.addListener(_reload); + widget.repository.addListener(_load); + _load(); } @override void dispose() { - widget.repository.removeListener(_reload); + widget.repository.removeListener(_load); super.dispose(); } - Future<({ShoppingList list, List items})> _load() async { - final list = await widget.repository.getShoppingList(widget.listId); - if (list == null) { - throw StateError('List not found'); + Future _load() async { + final token = ++_loadToken; + try { + final list = await widget.repository.getShoppingList(widget.listId); + if (list == null) throw StateError('List not found'); + final items = await widget.repository.getShoppingListItems(widget.listId); + if (!mounted || token != _loadToken) return; + setState(() { + _list = list; + _items = items; + _error = null; + }); + } catch (error) { + if (!mounted || token != _loadToken) return; + setState(() => _error = error); } - final items = await widget.repository.getShoppingListItems(widget.listId); - return (list: list, items: items); - } - - void _reload() { - if (!mounted) return; - setState(() => _future = _load()); } Future _rename(ShoppingList list) async { @@ -65,25 +73,70 @@ class _ShoppingListDetailScreenState extends State { ); if (name == null || !mounted) return; await widget.repository.renameShoppingList(widget.listId, name); - if (mounted) _reload(); + if (mounted) { + setState( + () => _list = ShoppingList( + id: list.id, + name: name, + itemCount: list.itemCount, + checkedCount: list.checkedCount, + createdAt: list.createdAt, + updatedAt: DateTime.now(), + ), + ); + _load(); + } } Future _openAddSheet() async { final products = await widget.repository.getAll(); if (!mounted) return; - final result = await showModalBottomSheet<({String name, int? productId})>( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (context) => _AddListItemSheet(products: products), - ); - if (result == null || !mounted) return; - await widget.repository.addShoppingListItem( - listId: widget.listId, - name: result.name, - productId: result.productId, - ); - if (mounted) _reload(); + final selections = await showAddListItemsSheet(context, products: products); + if (selections == null || selections.isEmpty || !mounted) return; + + final pending = [ + for (var i = 0; i < selections.length; i++) + ShoppingListItem( + id: -(i + 1), + listId: widget.listId, + name: selections[i].name, + checked: false, + sortOrder: (_items?.length ?? 0) + i, + productId: selections[i].productId, + ), + ]; + setState(() { + _items = [...?_items, ...pending]; + if (_list != null) { + _list = ShoppingList( + id: _list!.id, + name: _list!.name, + itemCount: _list!.itemCount + pending.length, + checkedCount: _list!.checkedCount, + createdAt: _list!.createdAt, + updatedAt: DateTime.now(), + ); + } + }); + + try { + final added = await widget.repository.addShoppingListItems( + listId: widget.listId, + items: [ + for (final selection in selections) + (name: selection.name, productId: selection.productId), + ], + ); + if (!mounted) return; + setState(() { + final withoutPending = _items + ?.where((item) => item.id >= 0) + .toList(growable: false); + _items = [...?withoutPending, ...added]; + }); + } catch (_) { + if (mounted) _load(); + } } Future _editItem(ShoppingListItem item) async { @@ -95,7 +148,7 @@ class _ShoppingListDetailScreenState extends State { ); if (name == null || !mounted) return; await widget.repository.updateShoppingListItem(id: item.id, name: name); - if (mounted) _reload(); + if (mounted) _load(); } Future _removeItem(ShoppingListItem item) async { @@ -106,16 +159,100 @@ class _ShoppingListDetailScreenState extends State { confirmLabel: 'Remove', ); if (!confirmed || !mounted) return; - await widget.repository.deleteShoppingListItem(item.id); - if (mounted) _reload(); + setState(() { + _items = [ + for (final entry in _items ?? const []) + if (entry.id != item.id) entry, + ]; + if (_list != null) { + _list = ShoppingList( + id: _list!.id, + name: _list!.name, + itemCount: _list!.itemCount - 1, + checkedCount: item.checked + ? _list!.checkedCount - 1 + : _list!.checkedCount, + createdAt: _list!.createdAt, + updatedAt: DateTime.now(), + ); + } + }); + try { + await widget.repository.deleteShoppingListItem(item.id); + } catch (_) { + if (mounted) _load(); + } } Future _toggle(ShoppingListItem item) async { - await widget.repository.updateShoppingListItem( - id: item.id, - checked: !item.checked, - ); - if (mounted) _reload(); + final next = !item.checked; + setState(() { + _items = [ + for (final entry in _items ?? const []) + if (entry.id == item.id) + ShoppingListItem( + id: entry.id, + listId: entry.listId, + name: entry.name, + checked: next, + sortOrder: entry.sortOrder, + productId: entry.productId, + ) + else + entry, + ]; + if (_list != null) { + _list = ShoppingList( + id: _list!.id, + name: _list!.name, + itemCount: _list!.itemCount, + checkedCount: _list!.checkedCount + (next ? 1 : -1), + createdAt: _list!.createdAt, + updatedAt: _list!.updatedAt, + ); + } + }); + try { + await widget.repository.updateShoppingListItem( + id: item.id, + checked: next, + ); + } catch (_) { + if (mounted) _load(); + } + } + + Future _shopAgain() async { + final list = _list; + if (list == null || list.checkedCount == 0) return; + + setState(() { + _items = [ + for (final item in _items ?? const []) + ShoppingListItem( + id: item.id, + listId: item.listId, + name: item.name, + checked: false, + sortOrder: item.sortOrder, + productId: item.productId, + ), + ]; + _list = ShoppingList( + id: list.id, + name: list.name, + itemCount: list.itemCount, + checkedCount: 0, + createdAt: list.createdAt, + updatedAt: DateTime.now(), + ); + }); + + try { + await widget.repository.resetShoppingListChecks(widget.listId); + } catch (_) { + if (mounted) _load(); + } } Future _openProduct(int productId) async { @@ -133,192 +270,112 @@ class _ShoppingListDetailScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); - return Scaffold( - body: FutureBuilder( - future: _future, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Scaffold( - appBar: AppBar(), - body: Center( - child: Text( - 'Could not load this list.', - style: theme.textTheme.bodyLarge, - ), - ), - ); - } - if (!snapshot.hasData) { - return Scaffold( - appBar: AppBar(), - body: const Center(child: CircularProgressIndicator()), - ); - } - - final list = snapshot.data!.list; - final items = snapshot.data!.items; - - return Scaffold( - appBar: AppBar( - title: Text(list.name), - actions: [ - IconButton( - tooltip: 'Rename', - onPressed: () => _rename(list), - icon: const Icon(Icons.drive_file_rename_outline), - ), - IconButton( - tooltip: _editing ? 'Done' : 'Edit', - onPressed: () => setState(() => _editing = !_editing), - icon: Icon(_editing ? Icons.check : Icons.edit_outlined), - ), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: _openAddSheet, - tooltip: 'Add item', - child: const Icon(Icons.add), - ), - body: items.isEmpty - ? Center( - child: Text( - 'Add things to buy.', - style: theme.textTheme.bodyLarge, - ), - ) - : ListView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return ListTile( - leading: Checkbox( - value: item.checked, - onChanged: _editing ? null : (_) => _toggle(item), - ), - title: Text( - item.name, - style: item.checked - ? theme.textTheme.bodyLarge?.copyWith( - decoration: TextDecoration.lineThrough, - color: theme.colorScheme.onSurfaceVariant, - ) - : null, - ), - trailing: _editing - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'Edit', - onPressed: () => _editItem(item), - icon: const Icon(Icons.edit_outlined), - ), - IconButton( - tooltip: 'Remove', - onPressed: () => _removeItem(item), - icon: const Icon(Icons.delete_outline), - ), - ], - ) - : item.productId == null - ? null - : IconButton( - tooltip: 'Product', - onPressed: () => _openProduct(item.productId!), - icon: const Icon(Icons.chevron_right), - ), - onTap: _editing ? null : () => _toggle(item), - ); - }, - ), - ); - }, - ), - ); - } -} - -class _AddListItemSheet extends StatefulWidget { - const _AddListItemSheet({required this.products}); - - final List products; - - @override - State<_AddListItemSheet> createState() => _AddListItemSheetState(); -} - -class _AddListItemSheetState extends State<_AddListItemSheet> { - final TextEditingController _controller = TextEditingController(); - String? _error; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - List get _matches { - final query = _controller.text.trim().toLowerCase(); - if (query.isEmpty) { - return widget.products.take(8).toList(); - } - return widget.products - .where((product) => product.name.toLowerCase().contains(query)) - .take(8) - .toList(); - } - - void _add({String? name, int? productId}) { - final value = (name ?? _controller.text).trim(); - if (value.isEmpty) { - setState(() => _error = 'Enter an item name.'); - return; - } - Navigator.of(context).pop((name: value, productId: productId)); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final bottom = MediaQuery.viewInsetsOf(context).bottom; - final matches = _matches; - - return Padding( - padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Add item', style: theme.textTheme.titleLarge), - const SizedBox(height: 16), - TextField( - controller: _controller, - autofocus: true, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration(labelText: 'Item name'), - onChanged: (_) => setState(() => _error = null), - onSubmitted: (_) => _add(), + if (_error != null && _list == null) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: Text( + 'Could not load this list.', + style: theme.textTheme.bodyLarge, + ), + ), + ); + } + if (_list == null || _items == null) { + return Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), + ); + } + + final list = _list!; + final items = _items!; + + return Scaffold( + appBar: AppBar( + title: Text('${list.name} (${formatDate(list.createdAt)})'), + actions: [ + if (list.checkedCount > 0 && !_editing) + TextButton( + onPressed: _shopAgain, + child: const Text('Shop again'), + ), + IconButton( + tooltip: 'Rename', + onPressed: () => _rename(list), + icon: const Icon(Icons.drive_file_rename_outline), + ), + IconButton( + tooltip: _editing ? 'Done' : 'Edit', + onPressed: () => setState(() => _editing = !_editing), + icon: Icon(_editing ? Icons.check : Icons.edit_outlined), ), - if (_error != null) ...[ - const SizedBox(height: 8), - Text(_error!, style: TextStyle(color: theme.colorScheme.error)), - ], - if (matches.isNotEmpty) ...[ - const SizedBox(height: 12), - Text('From products', style: theme.textTheme.labelMedium), - const SizedBox(height: 4), - for (final product in matches) - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(product.name), - onTap: () => _add(name: product.name, productId: product.id), - ), - ], - const SizedBox(height: 8), - FilledButton(onPressed: () => _add(), child: const Text('Add')), ], ), + floatingActionButton: FloatingActionButton( + onPressed: _openAddSheet, + tooltip: 'Add item', + child: const Icon(Icons.add), + ), + body: items.isEmpty + ? Center( + child: Text( + 'Add things to buy.', + style: theme.textTheme.bodyLarge, + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( + leading: Checkbox( + value: item.checked, + onChanged: _editing || item.id < 0 + ? null + : (_) => _toggle(item), + ), + title: Text( + item.name, + style: item.checked + ? theme.textTheme.bodyLarge?.copyWith( + decoration: TextDecoration.lineThrough, + color: theme.colorScheme.onSurfaceVariant, + ) + : null, + ), + trailing: _editing + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Edit', + onPressed: item.id < 0 + ? null + : () => _editItem(item), + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Remove', + onPressed: item.id < 0 + ? null + : () => _removeItem(item), + icon: const Icon(Icons.delete_outline), + ), + ], + ) + : item.productId == null + ? null + : IconButton( + tooltip: 'Product', + onPressed: () => _openProduct(item.productId!), + icon: const Icon(Icons.chevron_right), + ), + onTap: _editing || item.id < 0 ? null : () => _toggle(item), + ); + }, + ), ); } } diff --git a/lib/shopping_lists_screen.dart b/lib/shopping_lists_screen.dart index efa25b3..ebff341 100644 --- a/lib/shopping_lists_screen.dart +++ b/lib/shopping_lists_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'models/shopping_list.dart'; import 'services/product_repository.dart'; import 'shopping_list_detail_screen.dart'; +import 'utils/dates.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/name_dialog.dart'; import 'widgets/profile_button.dart'; @@ -88,7 +89,10 @@ class _ShoppingListsScreenState extends State { ); if (!confirmed || !mounted) return; setState(() { - _lists = [for (final item in _lists ?? const []) if (item.id != list.id) item]; + _lists = [ + for (final item in _lists ?? const []) + if (item.id != list.id) item, + ]; if (_lists!.isEmpty) _editing = false; }); try { @@ -147,7 +151,7 @@ class _ShoppingListsScreenState extends State { final remaining = list.itemCount - list.checkedCount; return ListTile( leading: CircleAvatar(child: Text('${list.itemCount}')), - title: Text(list.name), + title: Text('${list.name} (${formatDate(list.createdAt)})'), subtitle: Text( list.itemCount == 0 ? 'Empty' diff --git a/lib/supermarkets_screen.dart b/lib/supermarkets_screen.dart new file mode 100644 index 0000000..f1fdb98 --- /dev/null +++ b/lib/supermarkets_screen.dart @@ -0,0 +1,284 @@ +/// Manage supermarket names, colors, and bonus card photos. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'models/supermarket.dart'; +import 'services/app_settings.dart'; +import 'services/product_repository.dart'; +import 'widgets/store_edit_dialog.dart'; +import 'widgets/store_tag.dart'; + +class SupermarketsScreen extends StatefulWidget { + const SupermarketsScreen({super.key, required this.repository}); + + final ProductRepository repository; + + @override + State createState() => _SupermarketsScreenState(); +} + +class _SupermarketsScreenState extends State { + List? _stores; + bool _busy = false; + 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 { + try { + final stores = await widget.repository.getSupermarkets(); + if (!mounted) return; + setState(() => _stores = stores); + } catch (_) { + if (!mounted) return; + setState(() => _stores ??= const []); + } + } + + Future _syncSettings() async { + await _settings?.reload(); + } + + AppSettings? get _settings => SettingsScope.maybeOf(context); + + Future _editStore(Supermarket? existing) async { + final result = await showStoreEditDialog(context, store: existing); + if (result == null) return; + + setState(() => _busy = true); + try { + final settings = _settings; + if (settings == null) return; + if (existing == null) { + await settings.addSupermarket(result.name, result.colorValue); + } else { + await settings.updateSupermarket(result); + } + await _load(); + await _syncSettings(); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('That supermarket already exists.')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _deleteStore(Supermarket store) async { + if (store.id == null) return; + setState(() => _busy = true); + try { + await _settings?.deleteSupermarket(store.id!); + await _load(); + await _syncSettings(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _pickBonusCard(Supermarket store) async { + if (store.id == null) return; + 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) return; + final file = await _picker.pickImage(source: source, imageQuality: 85); + if (file == null) return; + setState(() => _busy = true); + try { + await widget.repository.setSupermarketBonusCard(store.id!, file.path); + await _load(); + await _syncSettings(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + 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), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final stores = _stores; + + return Scaffold( + appBar: AppBar(title: const Text('Supermarkets')), + floatingActionButton: FloatingActionButton( + onPressed: _busy ? null : () => _editStore(null), + tooltip: 'Add supermarket', + child: const Icon(Icons.add), + ), + body: stores == null + ? const Center(child: CircularProgressIndicator()) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), + itemCount: stores.length + 1, + separatorBuilder: (_, index) => + index == 0 ? const SizedBox(height: 4) : const Divider(height: 1), + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Store names and colors appear on products. ' + 'Add a bonus card photo when you have one.', + style: theme.textTheme.bodyMedium, + ), + if (_busy) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(), + ], + ], + ), + ); + } + final store = stores[index - 1]; + return _SupermarketRow( + store: store, + stores: stores, + busy: _busy, + onEdit: () => _editStore(store), + onDelete: store.id == null ? null : () => _deleteStore(store), + onBonusCard: () => _pickBonusCard(store), + onViewBonusCard: store.bonusImagePath == null + ? null + : () => _viewBonusCard(store.bonusImagePath!), + ); + }, + ), + ); + } +} + +class _SupermarketRow extends StatelessWidget { + const _SupermarketRow({ + required this.store, + required this.stores, + required this.busy, + required this.onEdit, + required this.onBonusCard, + this.onDelete, + this.onViewBonusCard, + }); + + final Supermarket store; + final List stores; + final bool busy; + final VoidCallback onEdit; + final VoidCallback? onDelete; + final VoidCallback onBonusCard; + final VoidCallback? onViewBonusCard; + + @override + Widget build(BuildContext context) { + final path = store.bonusImagePath; + final hasCard = path != null && path.isNotEmpty && File(path).existsSync(); + + return ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + leading: hasCard + ? InkWell( + onTap: busy ? null : onViewBonusCard, + borderRadius: BorderRadius.circular(8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(path), + width: 44, + height: 28, + fit: BoxFit.cover, + ), + ), + ) + : CircleAvatar( + radius: 16, + backgroundColor: store.color, + child: Text( + store.name.isEmpty ? '?' : store.name.substring(0, 1), + style: TextStyle( + color: store.color.computeLuminance() > 0.55 + ? Colors.black87 + : Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + title: StoreTag(name: store.name, stores: stores, compact: true), + subtitle: hasCard ? null : const Text('No bonus card'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: hasCard ? 'Replace bonus card' : 'Add bonus card', + onPressed: busy ? null : onBonusCard, + icon: Icon(hasCard ? Icons.photo_camera : Icons.card_membership_outlined), + ), + IconButton( + tooltip: 'Edit', + onPressed: busy ? null : onEdit, + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Delete', + onPressed: busy ? null : onDelete, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ); + } +} diff --git a/lib/utils/app_theme.dart b/lib/utils/app_theme.dart index 2b8d926..2bb65d2 100644 --- a/lib/utils/app_theme.dart +++ b/lib/utils/app_theme.dart @@ -39,9 +39,7 @@ abstract final class AppTheme { cardTheme: CardThemeData( elevation: 0, color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, ), @@ -60,9 +58,7 @@ abstract final class AppTheme { inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colorScheme.outlineVariant), @@ -88,9 +84,7 @@ abstract final class AppTheme { floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: colorScheme.primary, foregroundColor: colorScheme.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), dividerTheme: DividerThemeData( color: colorScheme.outlineVariant, @@ -98,14 +92,10 @@ abstract final class AppTheme { ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), listTileTheme: ListTileThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), iconColor: colorScheme.primary, ), shadowColor: isDark ? Colors.black : const Color(0x1A0F172A), diff --git a/lib/utils/product_names.dart b/lib/utils/product_names.dart new file mode 100644 index 0000000..d96ca84 --- /dev/null +++ b/lib/utils/product_names.dart @@ -0,0 +1,269 @@ +/// Case-insensitive product name matching and duplicate grouping. +library; + +import '../models/product.dart'; + +String normalizeProductName(String name) => + name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); + +String normalizeSupermarket(String? supermarket) => + supermarket?.trim().toUpperCase() ?? ''; + +String productConflictKey({ + required String name, + required String? supermarket, +}) => '${normalizeSupermarket(supermarket)}|${normalizeProductName(name)}'; + +String mergeGroupKey(List products, String? supermarket) { + final ids = products.map((product) => product.id).toList()..sort(); + return 'merge:${normalizeSupermarket(supermarket)}:' + '${normalizeProductName(products.first.name)}:${ids.join(',')}'; +} + +String spellingVariantKey(int productId, String? supermarket) => + 'spelling:$productId:${normalizeSupermarket(supermarket)}'; + +List> nameConflictGroups(Iterable products) { + final groups = >{}; + for (final product in products) { + final key = productConflictKey( + name: product.name, + supermarket: product.supermarket, + ); + if (normalizeProductName(product.name).isEmpty) continue; + (groups[key] ??= []).add(product); + } + return [ + for (final group in groups.values) + if (group.length > 1) group, + ]; +} + +Set nameConflictIds(Iterable products) { + return { + for (final group in nameConflictGroups(products)) + for (final product in group) product.id, + }; +} + +/// Separate catalog products with the same name at the same store. +class ProductMergeGroup { + const ProductMergeGroup({ + required this.products, + required this.supermarket, + required this.key, + }); + + final List products; + final String? supermarket; + final String key; +} + +/// One catalog product bought under multiple spellings at one store. +class ProductSpellingVariant { + const ProductSpellingVariant({ + required this.product, + required this.supermarket, + required this.variants, + required this.key, + }); + + final Product product; + final String? supermarket; + final List variants; + final String key; +} + +/// Builds merge groups from catalog names and trip lines at the same store. +List buildNameConflictGroups({ + required Iterable products, + required Iterable<({int productId, String name, String supermarket})> + shopLines, +}) { + final productById = {for (final product in products) product.id: product}; + final idsByKey = >{}; + final supermarketByKey = {}; + + void add(int productId, String name, String? supermarket) { + final key = productConflictKey(name: name, supermarket: supermarket); + if (normalizeProductName(name).isEmpty) return; + final trimmed = supermarket?.trim(); + if (trimmed != null && trimmed.isNotEmpty) { + supermarketByKey.putIfAbsent(key, () => trimmed); + } + (idsByKey[key] ??= {}).add(productId); + } + + for (final product in products) { + add(product.id, product.name, product.supermarket); + } + for (final line in shopLines) { + add(line.productId, line.name, line.supermarket); + } + + final groups = []; + for (final entry in idsByKey.entries) { + if (entry.value.length < 2) continue; + final productsInGroup = [ + for (final id in entry.value) + if (productById.containsKey(id)) productById[id]!, + ]; + if (productsInGroup.length < 2) continue; + + productsInGroup.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + final supermarketLabel = supermarketByKey[entry.key]; + groups.add( + ProductMergeGroup( + products: productsInGroup, + supermarket: supermarketLabel, + key: mergeGroupKey(productsInGroup, supermarketLabel), + ), + ); + } + + groups.sort( + (a, b) => + productConflictKey( + name: a.products.first.name, + supermarket: a.supermarket, + ).compareTo( + productConflictKey( + name: b.products.first.name, + supermarket: b.supermarket, + ), + ), + ); + return groups; +} + +/// Finds receipt spellings that differ only by capitals at one store. +List findSpellingVariants({ + required Iterable products, + required Iterable<({int productId, String name, String supermarket})> + shopLines, + Iterable mergeProductIds = const [], + Iterable mergeGroupKeys = const [], +}) { + final productById = {for (final product in products) product.id: product}; + final excludedProducts = mergeProductIds.toSet(); + final excludedGroups = mergeGroupKeys.toSet(); + final namesByProductStore = >{}; + final supermarketByKey = {}; + + void addName(int productId, String name, String supermarket) { + final storeKey = normalizeSupermarket(supermarket); + final bucketKey = '$productId|$storeKey'; + supermarketByKey[bucketKey] = supermarket; + (namesByProductStore[bucketKey] ??= {}).add(name); + } + + for (final line in shopLines) { + addName(line.productId, line.name, line.supermarket); + } + for (final product in products) { + final supermarket = product.supermarket; + if (supermarket == null || supermarket.trim().isEmpty) continue; + addName(product.id, product.name, supermarket); + } + + final issues = []; + for (final entry in namesByProductStore.entries) { + final parts = entry.key.split('|'); + final productId = int.parse(parts.first); + if (excludedProducts.contains(productId)) continue; + + final product = productById[productId]; + if (product == null) continue; + + final supermarket = supermarketByKey[entry.key]; + final byNormalized = >{}; + for (final name in entry.value) { + final key = normalizeProductName(name); + if (key.isEmpty) continue; + (byNormalized[key] ??= {}).add(name); + } + + for (final names in byNormalized.values) { + if (names.length < 2) continue; + final variants = names.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + final issue = ProductSpellingVariant( + product: product, + supermarket: supermarket, + variants: variants, + key: spellingVariantKey(productId, supermarket), + ); + if (excludedGroups.contains(issue.key)) continue; + issues.add(issue); + } + } + + issues.sort( + (a, b) => + productConflictKey( + name: a.product.name, + supermarket: a.supermarket, + ).compareTo( + productConflictKey(name: b.product.name, supermarket: b.supermarket), + ), + ); + return issues; +} + +/// Catalog merges and receipt spelling variants for the Products screen. +class ProductNameIssues { + const ProductNameIssues({ + required this.mergeGroups, + required this.spellingVariants, + }); + + final List mergeGroups; + final List spellingVariants; + + bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty; + + bool get isNotEmpty => !isEmpty; + + int get issueCount => mergeGroups.length + spellingVariants.length; + + Set get productIds => { + for (final group in mergeGroups) + for (final product in group.products) product.id, + for (final variant in spellingVariants) variant.product.id, + }; +} + +ProductNameIssues buildProductNameIssues({ + required Iterable products, + required Iterable<({int productId, String name, String supermarket})> + shopLines, + Set dismissedKeys = const {}, +}) { + final mergeGroups = [ + for (final group in buildNameConflictGroups( + products: products, + shopLines: shopLines, + )) + if (!dismissedKeys.contains(group.key)) group, + ]; + final mergeIds = { + for (final group in mergeGroups) + for (final product in group.products) product.id, + }; + final mergeKeys = {for (final group in mergeGroups) group.key}; + final spellingVariants = [ + for (final variant in findSpellingVariants( + products: products, + shopLines: shopLines, + mergeProductIds: mergeIds, + mergeGroupKeys: mergeKeys, + )) + if (!dismissedKeys.contains(variant.key)) variant, + ]; + return ProductNameIssues( + mergeGroups: mergeGroups, + spellingVariants: spellingVariants, + ); +} diff --git a/lib/widgets/add_list_items_sheet.dart b/lib/widgets/add_list_items_sheet.dart new file mode 100644 index 0000000..0e98dac --- /dev/null +++ b/lib/widgets/add_list_items_sheet.dart @@ -0,0 +1,175 @@ +/// Multi-select sheet for adding catalog products to a shopping list. +library; + +import 'package:flutter/material.dart'; + +import '../models/product.dart'; +import 'product_image.dart'; + +class AddListItemSelection { + const AddListItemSelection({required this.name, this.productId}); + + final String name; + final int? productId; +} + +Future?> showAddListItemsSheet( + BuildContext context, { + required List products, +}) { + return showModalBottomSheet>( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => _AddListItemsSheet(products: products), + ); +} + +class _AddListItemsSheet extends StatefulWidget { + const _AddListItemsSheet({required this.products}); + + final List products; + + @override + State<_AddListItemsSheet> createState() => _AddListItemsSheetState(); +} + +class _AddListItemsSheetState extends State<_AddListItemsSheet> { + final TextEditingController _controller = TextEditingController(); + final Set _selectedProductIds = {}; + String? _error; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + List get _matches { + final query = _controller.text.trim().toLowerCase(); + if (query.isEmpty) return widget.products; + return widget.products + .where((product) => product.name.toLowerCase().contains(query)) + .toList(); + } + + int get _addCount { + var count = _selectedProductIds.length; + final typed = _controller.text.trim(); + if (typed.isEmpty) return count; + final selectedNames = { + for (final product in widget.products) + if (_selectedProductIds.contains(product.id)) + product.name.toLowerCase(), + }; + if (!selectedNames.contains(typed.toLowerCase())) count++; + return count; + } + + void _toggleProduct(Product product) { + setState(() { + if (_selectedProductIds.contains(product.id)) { + _selectedProductIds.remove(product.id); + } else { + _selectedProductIds.add(product.id); + } + _error = null; + }); + } + + void _add() { + final selections = [ + for (final product in widget.products) + if (_selectedProductIds.contains(product.id)) + AddListItemSelection(name: product.name, productId: product.id), + ]; + final typed = _controller.text.trim(); + if (typed.isNotEmpty && + !selections.any( + (item) => item.name.toLowerCase() == typed.toLowerCase(), + )) { + selections.add(AddListItemSelection(name: typed)); + } + if (selections.isEmpty) { + setState(() => _error = 'Select products or enter an item name.'); + return; + } + Navigator.of(context).pop(selections); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bottom = MediaQuery.viewInsetsOf(context).bottom; + final matches = _matches; + final addCount = _addCount; + + return Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.72, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Add items', style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Search or type a new item', + ), + onChanged: (_) => setState(() => _error = null), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ], + const SizedBox(height: 8), + Text('From products', style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + Expanded( + child: matches.isEmpty + ? Center( + child: Text( + widget.products.isEmpty + ? 'No products in your catalog yet.' + : 'No products match this search.', + style: theme.textTheme.bodyMedium, + ), + ) + : ListView.builder( + itemCount: matches.length, + itemBuilder: (context, index) { + final product = matches[index]; + final selected = _selectedProductIds.contains( + product.id, + ); + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Checkbox( + value: selected, + onChanged: (_) => _toggleProduct(product), + ), + title: Text(product.name), + trailing: ProductImage( + name: product.name, + path: product.imagePath, + size: 32, + ), + onTap: () => _toggleProduct(product), + ); + }, + ), + ), + FilledButton( + onPressed: addCount == 0 ? null : _add, + child: Text(addCount == 1 ? 'Add 1 item' : 'Add $addCount items'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/discounted_price.dart b/lib/widgets/discounted_price.dart new file mode 100644 index 0000000..4d83ca8 --- /dev/null +++ b/lib/widgets/discounted_price.dart @@ -0,0 +1,37 @@ +/// Paid amount, with the pre-discount price struck through when there is one. +library; + +import 'package:flutter/material.dart'; + +import '../models/line_item.dart'; +import '../utils/money.dart'; + +class DiscountedPrice extends StatelessWidget { + const DiscountedPrice({super.key, required this.item}); + + final LineItem item; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final paid = Text( + formatEuro(item.price), + style: theme.textTheme.titleMedium, + ); + if (!item.hasDiscount) return paid; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formatEuro(item.originalPrice), + style: theme.textTheme.bodyMedium?.copyWith( + decoration: TextDecoration.lineThrough, + ), + ), + const SizedBox(width: 8), + paid, + ], + ); + } +} diff --git a/lib/widgets/item_edit_sheet.dart b/lib/widgets/item_edit_sheet.dart index e320d86..e4b440f 100644 --- a/lib/widgets/item_edit_sheet.dart +++ b/lib/widgets/item_edit_sheet.dart @@ -66,6 +66,9 @@ class _ItemEditSheetState extends State { unitPrice: quantity != null && quantity > 0 ? roundMoney(rounded / quantity) : widget.item?.unitPrice, + discount: widget.item?.price == rounded + ? widget.item?.discount ?? 0 + : 0, ), ); } diff --git a/lib/widgets/pick_shopping_list_sheet.dart b/lib/widgets/pick_shopping_list_sheet.dart new file mode 100644 index 0000000..a15d415 --- /dev/null +++ b/lib/widgets/pick_shopping_list_sheet.dart @@ -0,0 +1,90 @@ +/// Bottom sheet for picking a shopping list to add a product to. +library; + +import 'package:flutter/material.dart'; + +import '../models/shopping_list.dart'; +import '../services/product_repository.dart'; +import 'name_dialog.dart'; + +Future showPickShoppingListSheet( + BuildContext context, { + required ProductRepository repository, +}) async { + final lists = await repository.getShoppingLists(); + if (!context.mounted) return null; + + if (lists.isEmpty) { + final name = await showNameDialog( + context, + title: 'New shopping list', + label: 'List name', + confirmLabel: 'Create', + ); + if (name == null || !context.mounted) return null; + return repository.createShoppingList(name); + } + + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) { + return _PickShoppingListSheet(repository: repository, lists: lists); + }, + ); +} + +class _PickShoppingListSheet extends StatelessWidget { + const _PickShoppingListSheet({required this.repository, required this.lists}); + + final ProductRepository repository; + final List lists; + + Future _create(BuildContext context) async { + final name = await showNameDialog( + context, + title: 'New shopping list', + label: 'List name', + confirmLabel: 'Create', + ); + if (name == null || !context.mounted) return; + final id = await repository.createShoppingList(name); + if (context.mounted) Navigator.of(context).pop(id); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text('Add to list', style: theme.textTheme.titleLarge), + ), + for (final list in lists) + ListTile( + leading: const Icon(Icons.checklist_outlined), + title: Text(list.name), + subtitle: Text( + list.itemCount == 0 + ? 'Empty' + : list.itemCount == 1 + ? '1 item' + : '${list.itemCount} items', + ), + onTap: () => Navigator.of(context).pop(list.id), + ), + ListTile( + leading: const Icon(Icons.add), + title: const Text('Create new list'), + onTap: () => _create(context), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/price_chart.dart b/lib/widgets/price_chart.dart index 7e90f19..71626a0 100644 --- a/lib/widgets/price_chart.dart +++ b/lib/widgets/price_chart.dart @@ -28,9 +28,7 @@ class PriceChart extends StatelessWidget { height: 160, child: CustomPaint( painter: _PriceChartPainter( - prices: [ - for (final purchase in points) purchase.unitOrLinePrice, - ], + prices: [for (final purchase in points) purchase.unitOrLinePrice], lineColor: theme.colorScheme.primary, fillColor: theme.colorScheme.primary.withValues(alpha: 0.12), gridColor: theme.colorScheme.outlineVariant, @@ -87,7 +85,12 @@ class _PriceChartPainter extends CustomPainter { const right = 8.0; const top = 12.0; const bottom = 8.0; - final chart = Rect.fromLTRB(left, top, size.width - right, size.height - bottom); + final chart = Rect.fromLTRB( + left, + top, + size.width - right, + size.height - bottom, + ); final gridPaint = Paint() ..color = gridColor diff --git a/lib/widgets/product_image.dart b/lib/widgets/product_image.dart index b8bfb83..6533a34 100644 --- a/lib/widgets/product_image.dart +++ b/lib/widgets/product_image.dart @@ -63,7 +63,9 @@ class _LetterAvatar extends StatelessWidget { @override Widget build(BuildContext context) { final trimmed = name.trim(); - final letter = trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + final letter = trimmed.isEmpty + ? '?' + : trimmed.substring(0, 1).toUpperCase(); return ColoredBox( color: colorScheme.secondaryContainer, child: Center( diff --git a/lib/widgets/product_name_conflicts.dart b/lib/widgets/product_name_conflicts.dart new file mode 100644 index 0000000..73b0264 --- /dev/null +++ b/lib/widgets/product_name_conflicts.dart @@ -0,0 +1,618 @@ +/// Banner, list markers, and merge sheet for name duplicates. +library; + +import 'package:flutter/material.dart'; + +import '../models/product.dart'; +import '../services/product_repository.dart'; +import '../utils/money.dart'; +import '../utils/product_names.dart'; +import 'product_image.dart'; + +const IconData kProductNameConflictIcon = Icons.merge_type; + +class ProductNameConflictBanner extends StatelessWidget { + const ProductNameConflictBanner({ + super.key, + required this.issues, + required this.onTap, + }); + + final ProductNameIssues issues; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final title = _bannerTitle(issues); + final subtitle = _bannerSubtitle(issues); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Material( + color: scheme.errorContainer.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + child: Row( + children: [ + Icon(kProductNameConflictIcon, color: scheme.error), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + Icon(Icons.chevron_right, color: scheme.onSurfaceVariant), + ], + ), + ), + ), + ), + ); + } +} + +String _bannerTitle(ProductNameIssues issues) { + final count = issues.issueCount; + if (count == 1 && issues.mergeGroups.length == 1) { + return '1 duplicate product'; + } + if (count == 1 && issues.spellingVariants.length == 1) { + return '1 product, multiple spellings'; + } + return '$count name issues'; +} + +String _bannerSubtitle(ProductNameIssues issues) { + final examples = []; + for (final group in issues.mergeGroups.take(2)) { + final store = group.supermarket == null ? '' : ' @ ${group.supermarket}'; + examples.add( + '${group.products.map((product) => product.name).join(' / ')}$store', + ); + } + for (final variant in issues.spellingVariants.take(2)) { + final store = variant.supermarket == null + ? '' + : ' @ ${variant.supermarket}'; + examples.add('${variant.variants.join(' / ')}$store'); + } + return examples.join(', '); +} + +class ProductNameConflictMarker extends StatelessWidget { + const ProductNameConflictMarker({ + super.key, + this.onTap, + this.compact = false, + }); + + final VoidCallback? onTap; + final bool compact; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final icon = Icon( + kProductNameConflictIcon, + size: compact ? 14 : 20, + color: scheme.error, + ); + if (onTap == null) return icon; + if (compact) { + return Material( + color: scheme.errorContainer, + shape: const CircleBorder(), + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: Padding(padding: const EdgeInsets.all(2), child: icon), + ), + ); + } + return IconButton( + tooltip: 'Resolve name issues', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: onTap, + icon: icon, + ); + } +} + +Future showProductNameConflictSheet( + BuildContext context, { + required ProductRepository repository, + required ProductNameIssues issues, + int? focusProductId, +}) { + if (issues.isEmpty) return Future.value(false); + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (context) { + final size = MediaQuery.sizeOf(context); + return SizedBox( + width: size.width, + height: size.height * 0.85, + child: _ProductNameConflictSheet( + repository: repository, + issues: issues, + focusProductId: focusProductId, + ), + ); + }, + ).then((resolved) => resolved == true); +} + +class _ProductNameConflictSheet extends StatefulWidget { + const _ProductNameConflictSheet({ + required this.repository, + required this.issues, + this.focusProductId, + }); + + final ProductRepository repository; + final ProductNameIssues issues; + final int? focusProductId; + + @override + State<_ProductNameConflictSheet> createState() => + _ProductNameConflictSheetState(); +} + +class _ProductNameConflictSheetState extends State<_ProductNameConflictSheet> { + late List _mergeGroups; + late List _spellingVariants; + late Map _keepByMergeGroup; + late Map _spellingChoiceByIndex; + final Set _busyMergeGroups = {}; + final Set _busySpellingGroups = {}; + bool _resolvedAny = false; + + @override + void initState() { + super.initState(); + _mergeGroups = [for (final group in widget.issues.mergeGroups) group]; + _spellingVariants = [ + for (final variant in widget.issues.spellingVariants) variant, + ]; + _rebuildChoices(); + } + + void _rebuildChoices() { + _keepByMergeGroup = { + for (var i = 0; i < _mergeGroups.length; i++) + i: _defaultKeep(_mergeGroups[i].products), + }; + _spellingChoiceByIndex = { + for (var i = 0; i < _spellingVariants.length; i++) + i: _spellingVariants[i].variants.first, + }; + } + + int _defaultKeep(List group) { + final sorted = [...group] + ..sort((a, b) { + final seen = b.timesSeen.compareTo(a.timesSeen); + if (seen != 0) return seen; + return a.id.compareTo(b.id); + }); + return sorted.first.id; + } + + bool get _isEmpty => _mergeGroups.isEmpty && _spellingVariants.isEmpty; + + int? _focusedMergeIndex() { + final focus = widget.focusProductId; + if (focus == null) return null; + for (var i = 0; i < _mergeGroups.length; i++) { + if (_mergeGroups[i].products.any((product) => product.id == focus)) { + return i; + } + } + return null; + } + + int? _focusedSpellingIndex() { + final focus = widget.focusProductId; + if (focus == null) return null; + for (var i = 0; i < _spellingVariants.length; i++) { + if (_spellingVariants[i].product.id == focus) return i; + } + return null; + } + + void _maybeClose() { + if (_isEmpty && mounted) { + Navigator.of(context).pop(true); + } + } + + Future _mergeGroup(int index) async { + if (_busyMergeGroups.contains(index)) return; + final group = _mergeGroups[index]; + final keepId = _keepByMergeGroup[index]!; + setState(() => _busyMergeGroups.add(index)); + try { + await widget.repository.mergeProducts( + keepId: keepId, + sourceIds: [for (final product in group.products) product.id], + ); + if (!mounted) return; + setState(() { + _resolvedAny = true; + _busyMergeGroups.remove(index); + _mergeGroups.removeAt(index); + _rebuildChoices(); + }); + _maybeClose(); + } catch (_) { + if (!mounted) return; + setState(() => _busyMergeGroups.remove(index)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not merge these products.')), + ); + } + } + + Future _leaveMergeAsIs(int index) async { + final group = _mergeGroups[index]; + await widget.repository.dismissNameIssue(group.key); + if (!mounted) return; + setState(() { + _resolvedAny = true; + _mergeGroups.removeAt(index); + _rebuildChoices(); + }); + _maybeClose(); + } + + Future _applySpelling(int index) async { + if (_busySpellingGroups.contains(index)) return; + final variant = _spellingVariants[index]; + final chosen = _spellingChoiceByIndex[index]!; + setState(() => _busySpellingGroups.add(index)); + try { + await widget.repository.unifyProductSpelling( + productId: variant.product.id, + name: chosen, + supermarket: variant.supermarket, + ); + if (!mounted) return; + setState(() { + _resolvedAny = true; + _busySpellingGroups.remove(index); + _spellingVariants.removeAt(index); + _rebuildChoices(); + }); + _maybeClose(); + } catch (_) { + if (!mounted) return; + setState(() => _busySpellingGroups.remove(index)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not update this spelling.')), + ); + } + } + + Future _leaveSpellingAsIs(int index) async { + final variant = _spellingVariants[index]; + await widget.repository.dismissNameIssue(variant.key); + if (!mounted) return; + setState(() { + _resolvedAny = true; + _spellingVariants.removeAt(index); + _rebuildChoices(); + }); + _maybeClose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final focusMergeIndex = _focusedMergeIndex(); + final focusSpellingIndex = _focusedSpellingIndex(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Resolve name issues', style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + 'Same name at the same store. Pick which entry to keep, ' + 'then merge or leave as separate products.', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: _isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _resolvedAny + ? 'All name issues resolved.' + : 'No name issues found.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ), + ) + : ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + children: [ + if (_mergeGroups.isNotEmpty) ...[ + Text( + 'Separate products to merge', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + for (var i = 0; i < _mergeGroups.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + _MergeGroupCard( + group: _mergeGroups[i], + keepId: _keepByMergeGroup[i]!, + busy: _busyMergeGroups.contains(i), + highlighted: focusMergeIndex == i, + onKeepChanged: (id) { + setState(() => _keepByMergeGroup[i] = id); + }, + onMerge: () => _mergeGroup(i), + onLeaveAsIs: () => _leaveMergeAsIs(i), + ), + ], + ], + if (_spellingVariants.isNotEmpty) ...[ + if (_mergeGroups.isNotEmpty) const SizedBox(height: 20), + Text( + 'One product, multiple spellings', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + for (var i = 0; i < _spellingVariants.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + _SpellingVariantCard( + variant: _spellingVariants[i], + chosen: _spellingChoiceByIndex[i]!, + busy: _busySpellingGroups.contains(i), + highlighted: focusSpellingIndex == i, + onChosen: (name) { + setState(() => _spellingChoiceByIndex[i] = name); + }, + onApply: () => _applySpelling(i), + onLeaveAsIs: () => _leaveSpellingAsIs(i), + ), + ], + ], + ], + ), + ), + ], + ); + } +} + +class _MergeGroupCard extends StatelessWidget { + const _MergeGroupCard({ + required this.group, + required this.keepId, + required this.busy, + required this.highlighted, + required this.onKeepChanged, + required this.onMerge, + required this.onLeaveAsIs, + }); + + final ProductMergeGroup group; + final int keepId; + final bool busy; + final bool highlighted; + final ValueChanged onKeepChanged; + final VoidCallback onMerge; + final VoidCallback onLeaveAsIs; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final products = group.products; + + return Card( + color: highlighted + ? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35) + : null, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + kProductNameConflictIcon, + size: 18, + color: theme.colorScheme.tertiary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + [ + normalizeProductName(products.first.name), + if (group.supermarket != null) group.supermarket!, + ].join(' · '), + style: theme.textTheme.titleSmall, + ), + ), + ], + ), + const SizedBox(height: 8), + for (final product in products) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: ProductImage( + name: product.name, + path: product.imagePath, + size: 36, + ), + title: Text(product.name), + subtitle: Text( + [ + formatEuro(product.lastPrice), + product.timesSeen == 1 + ? '1 purchase' + : '${product.timesSeen} purchases', + ].join(' · '), + ), + trailing: Icon( + keepId == product.id + ? Icons.check_circle + : Icons.circle_outlined, + color: keepId == product.id + ? theme.colorScheme.primary + : theme.colorScheme.outline, + ), + onTap: busy ? null : () => onKeepChanged(product.id), + ), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + TextButton( + onPressed: busy ? null : onLeaveAsIs, + child: const Text('Leave as is'), + ), + FilledButton( + onPressed: busy ? null : onMerge, + child: busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Merge'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _SpellingVariantCard extends StatelessWidget { + const _SpellingVariantCard({ + required this.variant, + required this.chosen, + required this.busy, + required this.highlighted, + required this.onChosen, + required this.onApply, + required this.onLeaveAsIs, + }); + + final ProductSpellingVariant variant; + final String chosen; + final bool busy; + final bool highlighted; + final ValueChanged onChosen; + final VoidCallback onApply; + final VoidCallback onLeaveAsIs; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final product = variant.product; + + return Card( + color: highlighted + ? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35) + : null, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: ProductImage( + name: product.name, + path: product.imagePath, + size: 36, + ), + title: Text(product.name), + subtitle: Text( + [ + if (variant.supermarket != null) variant.supermarket!, + product.timesSeen == 1 + ? '1 purchase' + : '${product.timesSeen} purchases', + ].join(' · '), + ), + ), + const SizedBox(height: 4), + Text('Recorded as:', style: theme.textTheme.labelLarge), + const SizedBox(height: 4), + for (final name in variant.variants) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(name), + trailing: Icon( + chosen == name ? Icons.check_circle : Icons.circle_outlined, + color: chosen == name + ? theme.colorScheme.primary + : theme.colorScheme.outline, + ), + onTap: busy ? null : () => onChosen(name), + ), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + TextButton( + onPressed: busy ? null : onLeaveAsIs, + child: const Text('Leave as is'), + ), + FilledButton( + onPressed: busy ? null : onApply, + child: Text(busy ? 'Saving…' : 'Use this spelling'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/profile_button.dart b/lib/widgets/profile_button.dart index de88a38..34c6b6e 100644 --- a/lib/widgets/profile_button.dart +++ b/lib/widgets/profile_button.dart @@ -13,9 +13,9 @@ class ProfileButton extends StatelessWidget { return IconButton( tooltip: 'Profile', onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ProfileScreen()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const ProfileScreen())); }, icon: const Icon(Icons.person_outline), ); diff --git a/lib/widgets/settings_button.dart b/lib/widgets/settings_button.dart index 32e88c4..86ac419 100644 --- a/lib/widgets/settings_button.dart +++ b/lib/widgets/settings_button.dart @@ -3,9 +3,9 @@ library; import 'package:flutter/material.dart'; -import '../models/supermarket.dart'; import '../services/app_settings.dart'; import '../services/backup_service.dart'; +import '../supermarkets_screen.dart'; class SettingsButton extends StatelessWidget { const SettingsButton({super.key}); @@ -47,9 +47,8 @@ class _SettingsSheetState extends State<_SettingsSheet> { final shared = await BackupService(settings.repository).exportBackup(); if (!mounted) return; if (shared) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Backup ready.')), - ); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Backup ready.'))); } } catch (_) { if (!mounted) return; @@ -92,9 +91,8 @@ class _SettingsSheetState extends State<_SettingsSheet> { if (imported) { await settings.reload(); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Backup imported.')), - ); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Backup imported.'))); } } catch (_) { if (!mounted) return; @@ -106,6 +104,16 @@ class _SettingsSheetState extends State<_SettingsSheet> { } } + void _openSupermarkets(BuildContext context, AppSettings settings) { + Navigator.of(context) + ..pop() + ..push( + MaterialPageRoute( + builder: (_) => SupermarketsScreen(repository: settings.repository), + ), + ); + } + @override Widget build(BuildContext context) { final settings = SettingsScope.of(context); @@ -116,257 +124,66 @@ class _SettingsSheetState extends State<_SettingsSheet> { builder: (context, _) { return Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), - child: SizedBox( - height: MediaQuery.sizeOf(context).height * 0.75, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Settings', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Dark mode'), - value: settings.darkMode, - onChanged: _busy ? null : settings.setDarkMode, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Settings', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Dark mode'), + value: settings.darkMode, + onChanged: _busy ? null : settings.setDarkMode, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.store_outlined), + title: const Text('Supermarkets'), + subtitle: Text( + settings.supermarkets.isEmpty + ? 'Names, colors, and bonus cards' + : '${settings.supermarkets.length} stores', ), - Text('Backup', style: Theme.of(context).textTheme.titleMedium), - Text( - 'Save everything to a JSON file, or replace this device from a backup.', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _busy ? null : () => _export(settings), - icon: const Icon(Icons.file_upload_outlined), - label: const Text('Export'), - ), + trailing: const Icon(Icons.chevron_right), + onTap: _busy + ? null + : () => _openSupermarkets(context, settings), + ), + const SizedBox(height: 16), + Text('Backup', style: Theme.of(context).textTheme.titleMedium), + Text( + 'Save everything to a JSON file, or replace this device from a backup.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _export(settings), + icon: const Icon(Icons.file_upload_outlined), + label: const Text('Export'), ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: _busy ? null : () => _import(settings), - icon: const Icon(Icons.file_download_outlined), - label: const Text('Import'), - ), - ), - ], - ), - if (_busy) ...[ - const SizedBox(height: 8), - const LinearProgressIndicator(), - ], - const SizedBox(height: 16), - Text( - 'Supermarkets', - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - 'Name and color used as tags on products.', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Expanded( - child: ListView( - children: [ - for (final store in settings.supermarkets) - _StoreRow( - store: store, - onEdit: _busy - ? () {} - : () => _editStore(context, settings, store), - onDelete: _busy || store.id == null - ? null - : () => settings.deleteSupermarket(store.id!), - ), - ], ), - ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _import(settings), + icon: const Icon(Icons.file_download_outlined), + label: const Text('Import'), + ), + ), + ], + ), + if (_busy) ...[ const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _busy - ? null - : () => _editStore(context, settings, null), - icon: const Icon(Icons.add), - label: const Text('Add supermarket'), - ), + const LinearProgressIndicator(), ], - ), + ], ), ); }, ); } - - Future _editStore( - BuildContext context, - AppSettings settings, - Supermarket? existing, - ) async { - final result = await showDialog( - context: context, - builder: (context) => _StoreEditDialog(store: existing), - ); - if (result == null) return; - if (existing == null) { - try { - await settings.addSupermarket(result.name, result.colorValue); - } catch (_) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('That supermarket already exists.')), - ); - } - } - } else { - await settings.updateSupermarket( - existing.copyWith(name: result.name, colorValue: result.colorValue), - ); - } - } -} - -class _StoreRow extends StatelessWidget { - const _StoreRow({ - required this.store, - required this.onEdit, - this.onDelete, - }); - - final Supermarket store; - final VoidCallback onEdit; - final VoidCallback? onDelete; - - @override - Widget build(BuildContext context) { - final onColor = store.color.computeLuminance() > 0.55 - ? Colors.black87 - : Colors.white; - - return ListTile( - contentPadding: EdgeInsets.zero, - leading: CircleAvatar( - backgroundColor: store.color, - child: Text( - store.name.isEmpty ? '?' : store.name.substring(0, 1), - style: TextStyle(color: onColor, fontWeight: FontWeight.w700), - ), - ), - title: Text(store.name), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'Edit', - onPressed: onEdit, - icon: const Icon(Icons.edit_outlined), - ), - IconButton( - tooltip: 'Delete', - onPressed: onDelete, - icon: const Icon(Icons.delete_outline), - ), - ], - ), - ); - } -} - -class _StoreEditDialog extends StatefulWidget { - const _StoreEditDialog({this.store}); - - final Supermarket? store; - - @override - State<_StoreEditDialog> createState() => _StoreEditDialogState(); -} - -class _StoreEditDialogState extends State<_StoreEditDialog> { - late final TextEditingController _name; - late int _color; - - @override - void initState() { - super.initState(); - _name = TextEditingController(text: widget.store?.name ?? ''); - _color = widget.store?.colorValue ?? kStoreColorPalette.first; - } - - @override - void dispose() { - _name.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.store == null ? 'Add supermarket' : 'Edit supermarket'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _name, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration(labelText: 'Name'), - autofocus: widget.store == null, - ), - const SizedBox(height: 16), - Align( - alignment: Alignment.centerLeft, - child: Text('Color', style: Theme.of(context).textTheme.titleMedium), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final value in kStoreColorPalette) - GestureDetector( - onTap: () => setState(() => _color = value), - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Color(value), - shape: BoxShape.circle, - border: Border.all( - color: _color == value - ? Theme.of(context).colorScheme.onSurface - : Colors.transparent, - width: 2, - ), - ), - ), - ), - ], - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final name = _name.text.trim(); - if (name.isEmpty) return; - Navigator.pop( - context, - Supermarket( - id: widget.store?.id, - name: name, - colorValue: _color, - sortOrder: widget.store?.sortOrder ?? 0, - ), - ); - }, - child: const Text('Save'), - ), - ], - ); - } } diff --git a/lib/widgets/store_edit_dialog.dart b/lib/widgets/store_edit_dialog.dart new file mode 100644 index 0000000..8ee041c --- /dev/null +++ b/lib/widgets/store_edit_dialog.dart @@ -0,0 +1,119 @@ +/// Dialog for adding or editing a supermarket name and color. +library; + +import 'package:flutter/material.dart'; + +import '../models/supermarket.dart'; + +Future showStoreEditDialog( + BuildContext context, { + Supermarket? store, +}) { + return showDialog( + context: context, + builder: (context) => _StoreEditDialog(store: store), + ); +} + +class _StoreEditDialog extends StatefulWidget { + const _StoreEditDialog({this.store}); + + final Supermarket? store; + + @override + State<_StoreEditDialog> createState() => _StoreEditDialogState(); +} + +class _StoreEditDialogState extends State<_StoreEditDialog> { + late final TextEditingController _name; + late int _color; + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.store?.name ?? ''); + _color = widget.store?.colorValue ?? kStoreColorPalette.first; + } + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + widget.store == null ? 'Add supermarket' : 'Edit supermarket', + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: 'Name'), + autofocus: widget.store == null, + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Color', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final value in kStoreColorPalette) + GestureDetector( + onTap: () => setState(() => _color = value), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Color(value), + shape: BoxShape.circle, + border: Border.all( + color: _color == value + ? Theme.of(context).colorScheme.onSurface + : Colors.transparent, + width: 2, + ), + ), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final name = _name.text.trim(); + if (name.isEmpty) return; + Navigator.pop( + context, + Supermarket( + id: widget.store?.id, + name: name, + colorValue: _color, + sortOrder: widget.store?.sortOrder ?? 0, + bonusImagePath: widget.store?.bonusImagePath, + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/test/catalog_test.dart b/test/catalog_test.dart index 6fe2e0e..ca72d3a 100644 --- a/test/catalog_test.dart +++ b/test/catalog_test.dart @@ -6,8 +6,29 @@ import 'test_helpers.dart'; void main() { ensureTestSqlite(); - test('deleting a product keeps the trip line and drops the catalog row', - () async { + 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('addShopItem appends a line and updates the trip total', () async { final repository = memoryRepository(); final shopId = await repository.saveShop( supermarket: 'Jumbo', @@ -15,15 +36,20 @@ void main() { items: [LineItem(id: '1', name: 'Milk', price: 1.5)], ); - final products = await repository.getAll(); - expect(products, hasLength(1)); - await repository.deleteProduct(products.single.id); + await repository.addShopItem( + shopId: shopId, + item: LineItem(id: '2', name: 'Bread', price: 2), + ); - expect(await repository.getAll(), isEmpty); + final shop = (await repository.getShop(shopId))!; + expect(shop.total, 3.5); final items = await repository.getShopItems(shopId); - expect(items, hasLength(1)); - expect(items.single.name, 'Milk'); - expect(items.single.productId, isNull); + expect(items, hasLength(2)); + expect(items.map((item) => item.name), ['Milk', 'Bread']); + expect((await repository.getAll()).map((product) => product.name).toSet(), { + 'Milk', + 'Bread', + }); }); test('deleting all products leaves shopping trips in place', () async { diff --git a/test/fuzzy_test.dart b/test/fuzzy_test.dart index fabdbfb..86f7880 100644 --- a/test/fuzzy_test.dart +++ b/test/fuzzy_test.dart @@ -10,6 +10,9 @@ void main() { }); test('ranks closer matches higher', () { - expect(fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!, isTrue); + expect( + fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!, + isTrue, + ); }); } diff --git a/test/product_names_test.dart b/test/product_names_test.dart new file mode 100644 index 0000000..6f8ee87 --- /dev/null +++ b/test/product_names_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:receipity/models/line_item.dart'; +import 'package:receipity/models/product.dart'; +import 'package:receipity/utils/product_names.dart'; + +import 'test_helpers.dart'; + +Product _product(int id, String name, {String? supermarket}) => Product( + id: id, + name: name, + lastPrice: 1, + timesSeen: 1, + updatedAt: DateTime(2026, 1, 1), + supermarket: supermarket, +); + +void main() { + ensureTestSqlite(); + + group('nameConflictGroups', () { + test('groups products that differ only by case at the same store', () { + final groups = nameConflictGroups([ + _product(1, 'Broccoli', supermarket: 'Jumbo'), + _product(2, 'BROCCOLI', supermarket: 'Jumbo'), + _product(3, 'Milk', supermarket: 'Jumbo'), + ]); + + expect(groups, hasLength(1)); + expect(groups.single.map((product) => product.id), [1, 2]); + }); + + test('ignores same name at different stores', () { + final groups = nameConflictGroups([ + _product(1, 'Broccoli', supermarket: 'Jumbo'), + _product(2, 'BROCCOLI', supermarket: 'Lidl'), + ]); + + expect(groups, isEmpty); + }); + }); + + group('buildNameConflictGroups', () { + test('finds conflicts from trip lines linked to different products', () { + final groups = buildNameConflictGroups( + products: [ + _product(1, 'Broccoli', supermarket: 'Jumbo'), + _product(2, 'BROCCOLI', supermarket: 'Jumbo'), + ], + shopLines: const [ + (productId: 1, name: 'Broccoli', supermarket: 'Jumbo'), + (productId: 2, name: 'BROCCOLI', supermarket: 'Jumbo'), + ], + ); + + expect(groups, hasLength(1)); + expect(groups.single.products.map((product) => product.id), [1, 2]); + expect(groups.single.supermarket, 'Jumbo'); + }); + }); + + group('findSpellingVariants', () { + test('finds multiple receipt spellings for one product at one store', () { + final variants = findSpellingVariants( + products: [_product(1, 'Broccoli', supermarket: 'Jumbo')], + shopLines: const [ + (productId: 1, name: 'Broccoli', supermarket: 'Jumbo'), + (productId: 1, name: 'BROCCOLI', supermarket: 'Jumbo'), + ], + ); + + expect(variants, hasLength(1)); + expect(variants.single.product.id, 1); + expect(variants.single.supermarket, 'Jumbo'); + expect(variants.single.variants, ['Broccoli', 'BROCCOLI']); + }); + + test('ignores different spellings at different stores', () { + final variants = findSpellingVariants( + products: [_product(1, 'Broccoli', supermarket: 'Jumbo')], + shopLines: const [ + (productId: 1, name: 'Broccoli', supermarket: 'Jumbo'), + (productId: 1, name: 'BROCCOLI', supermarket: 'Lidl'), + ], + ); + + expect(variants, isEmpty); + }); + }); + + test('same name at different supermarkets creates separate products', () async { + final repository = memoryRepository(); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 1), + items: [LineItem(id: '1', name: 'Milk', price: 1.5)], + ); + await repository.saveShop( + supermarket: 'Lidl', + shoppedAt: DateTime(2026, 8, 2), + items: [LineItem(id: '2', name: 'Milk', price: 1.2)], + ); + + final products = await repository.getAll(); + expect(products, hasLength(2)); + expect(products.map((p) => p.supermarket).toSet(), {'Jumbo', 'Lidl'}); + expect(await repository.getProductNameIssues(), isEmpty); + }); + + test('getNameConflictGroups finds duplicates in the database', () async { + final repository = memoryRepository(); + await seedCaseConflictCatalog(repository); + + final groups = await repository.getNameConflictGroups(); + expect(groups, hasLength(1)); + expect(groups.single.map((product) => product.name).toSet(), { + 'Broccoli', + 'BROCCOLI', + }); + }); + + test('getProductNameIssues finds receipt spelling variants', () async { + final repository = memoryRepository(); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 1), + items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)], + ); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 2), + items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)], + ); + + final issues = await repository.getProductNameIssues(); + expect(issues.mergeGroups, isEmpty); + expect(issues.spellingVariants, hasLength(1)); + expect(issues.spellingVariants.single.supermarket, 'Jumbo'); + expect(issues.spellingVariants.single.variants, ['Broccoli', 'BROCCOLI']); + }); + + test('dismissNameIssue hides a spelling variant', () async { + final repository = memoryRepository(); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 1), + items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)], + ); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 2), + items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)], + ); + + final issue = + (await repository.getProductNameIssues()).spellingVariants.single; + await repository.dismissNameIssue(issue.key); + + expect(await repository.getProductNameIssues(), isEmpty); + }); + + test('mergeProducts combines purchases onto the kept product', () async { + final repository = memoryRepository(); + await seedCaseConflictCatalog(repository); + + await repository.mergeProducts(keepId: 1, sourceIds: [2]); + + final after = await repository.getAll(); + expect(after, hasLength(1)); + expect(after.single.name, 'Broccoli'); + expect(after.single.timesSeen, 2); + expect(await repository.getPurchases(1), hasLength(2)); + }); + + test('unifyProductSpelling updates catalog and receipt lines', () async { + final repository = memoryRepository(); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 1), + items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)], + ); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 2), + items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)], + ); + final product = (await repository.getAll()).single; + + await repository.unifyProductSpelling( + productId: product.id, + name: 'BROCCOLI', + supermarket: 'Jumbo', + ); + + final updated = (await repository.getProduct(product.id))!; + expect(updated.name, 'BROCCOLI'); + expect((await repository.getProductNameIssues()).isEmpty, isTrue); + }); +} diff --git a/test/receipt_parser_test.dart b/test/receipt_parser_test.dart index 56e9308..f137add 100644 --- a/test/receipt_parser_test.dart +++ b/test/receipt_parser_test.dart @@ -56,6 +56,7 @@ Totaal korting: -2,00 expect(result.items.map((item) => item.name), ['AARDBEIEN']); expect(result.items.single.price, 5.98); expect(result.items.single.discount, -2.00); + expect(result.items.single.originalPrice, 7.98); expect(result.validationPassed, isTrue); }); diff --git a/test/test_helpers.dart b/test/test_helpers.dart index 99f0bb3..6bd54c5 100644 --- a/test/test_helpers.dart +++ b/test/test_helpers.dart @@ -39,3 +39,70 @@ Future pumpUntilFound( } throw TestFailure('Timed out waiting for $finder'); } + +const _seedTs = '2026-08-01T00:00:00.000'; + +Future seedCaseConflictCatalog(ProductRepository repository) async { + await repository.replaceAllData( + darkMode: false, + supermarkets: [ + {'id': 1, 'name': 'Jumbo', 'color': 0xFFEEC21B, 'sort_order': 0}, + ], + products: [ + { + 'id': 1, + 'name': 'Broccoli', + 'normalized_name': 'BROCCOLI_A', + 'last_price': 1.5, + 'times_seen': 1, + 'favourite': 0, + 'supermarket': 'Jumbo', + 'created_at': _seedTs, + 'updated_at': _seedTs, + }, + { + 'id': 2, + 'name': 'BROCCOLI', + 'normalized_name': 'BROCCOLI_B', + 'last_price': 1.8, + 'times_seen': 1, + 'favourite': 0, + 'supermarket': 'Jumbo', + 'created_at': _seedTs, + 'updated_at': _seedTs, + }, + ], + shops: [ + { + 'id': 1, + 'supermarket': 'Jumbo', + 'shopped_at': '2026-08-01T00:00:00.000', + 'total': 1.5, + 'created_at': _seedTs, + }, + { + 'id': 2, + 'supermarket': 'Jumbo', + 'shopped_at': '2026-08-02T00:00:00.000', + 'total': 1.8, + 'created_at': _seedTs, + }, + ], + shopItems: [ + { + 'id': 1, + 'shop_id': 1, + 'product_id': 1, + 'name': 'Broccoli', + 'price': 1.5, + }, + { + 'id': 2, + 'shop_id': 2, + 'product_id': 2, + 'name': 'BROCCOLI', + 'price': 1.8, + }, + ], + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 61e3ed9..cef3381 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -232,4 +232,115 @@ void main() { expect(find.text('Jumbo'), findsWidgets); }); + + testWidgets('review screen strikes through the pre-discount price', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: ReviewScreen( + items: [ + LineItem(id: '1', name: 'Berries', price: 6.85, discount: -1.15), + ], + rawText: 'Berries 8.00\nKorting -1.15', + repository: memoryRepository(), + ), + ), + ); + + expect(find.text('€ 8.00'), findsOneWidget); + expect(find.text('€ 6.85'), findsOneWidget); + final original = tester.widget(find.text('€ 8.00')); + expect(original.style?.decoration, TextDecoration.lineThrough); + }); + + testWidgets('product name is saved when leaving the screen', (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.text('Milk')); + await pumpUntilFound(tester, find.text('Name')); + + await tester.enterText(find.byType(TextField).first, 'Oat milk'); + await tester.tap(find.byTooltip('Back')); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + await tester.pump(); + expect(find.byType(TextField), findsNothing); + expect(find.text('Oat milk'), findsOneWidget); + expect(find.text('Milk'), findsNothing); + }); + + testWidgets('products screen marks case-only duplicates', (tester) async { + final repository = memoryRepository(); + await tester.runAsync(() => seedCaseConflictCatalog(repository)); + + await tester.pumpWidget(ReceipityApp(repository: repository)); + await pumpUntilFound(tester, find.text('Overview')); + await tester.tap(find.text('Products').last); + await pumpUntilFound(tester, find.text('1 duplicate product')); + + expect(find.text('Broccoli'), findsOneWidget); + expect(find.text('BROCCOLI'), findsOneWidget); + expect(find.byTooltip('Resolve name issues'), findsWidgets); + }); + + testWidgets('products screen marks receipt spelling variants', ( + tester, + ) async { + final repository = memoryRepository(); + await tester.runAsync(() async { + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 1), + items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)], + ); + await repository.saveShop( + supermarket: 'Jumbo', + shoppedAt: DateTime(2026, 8, 2), + items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)], + ); + }); + + await tester.pumpWidget(ReceipityApp(repository: repository)); + await pumpUntilFound(tester, find.text('Overview')); + await tester.tap(find.text('Products').last); + await pumpUntilFound(tester, find.text('1 product, multiple spellings')); + + expect(find.text('Broccoli'), findsOneWidget); + expect(find.byTooltip('Resolve name issues'), findsWidgets); + }); + + testWidgets('resolve name issues sheet shows merge and leave as is', ( + tester, + ) async { + final repository = memoryRepository(); + await tester.runAsync(() => seedCaseConflictCatalog(repository)); + + await tester.pumpWidget(ReceipityApp(repository: repository)); + await pumpUntilFound(tester, find.text('Overview')); + await tester.tap(find.text('Products').last); + await pumpUntilFound(tester, find.text('1 duplicate product')); + + await tester.tap(find.text('1 duplicate product')); + await pumpUntilFound(tester, find.text('Resolve name issues')); + + expect(find.text('Resolve name issues'), findsOneWidget); + expect(find.text('Separate products to merge'), findsOneWidget); + expect(find.text('Broccoli'), findsWidgets); + expect(find.text('BROCCOLI'), findsWidgets); + expect(find.text('Merge'), findsOneWidget); + expect(find.text('Leave as is'), findsOneWidget); + }); }