diff --git a/lib/home_shell.dart b/lib/home_shell.dart index cce7c93..8359e8f 100644 --- a/lib/home_shell.dart +++ b/lib/home_shell.dart @@ -22,6 +22,7 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State { static const _addIndex = 2; static const _shopLogIndex = 3; + static const _fabGap = 56.0; int _index = _addIndex; int _revision = 0; @@ -65,8 +66,15 @@ class _HomeShellState extends State { }); } + void _select(int index) { + if (index == _index) return; + setState(() => _index = index); + } + @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Scaffold( body: switch (_index) { 0 => ProductsScreen( @@ -86,29 +94,105 @@ class _HomeShellState extends State { repository: widget.repository, ), }, - bottomNavigationBar: NavigationBar( - selectedIndex: _index, - onDestinationSelected: (index) => setState(() => _index = index), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.inventory_2_outlined), - selectedIcon: Icon(Icons.inventory_2), - label: 'Products', - ), - NavigationDestination( - icon: Icon(Icons.checklist_outlined), - selectedIcon: Icon(Icons.checklist), - label: 'Lists', - ), - NavigationDestination( - icon: Icon(Icons.add), - selectedIcon: Icon(Icons.add_circle), - label: 'Add', - ), - NavigationDestination( - icon: Icon(Icons.receipt_long_outlined), - selectedIcon: Icon(Icons.receipt_long), - label: 'Shop log', + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + floatingActionButton: FloatingActionButton.small( + onPressed: () => _select(_addIndex), + tooltip: 'Add', + elevation: _index == _addIndex ? 4 : 1, + shape: const CircleBorder(), + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + child: const Icon(Icons.add, size: 24), + ), + bottomNavigationBar: BottomAppBar( + shape: const CircularNotchedRectangle(), + notchMargin: 6, + elevation: 2, + height: 52, + padding: EdgeInsets.zero, + color: scheme.surfaceContainer, + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Expanded( + child: _NavButton( + icon: Icons.inventory_2_outlined, + selectedIcon: Icons.inventory_2, + label: 'Products', + selected: _index == 0, + onTap: () => _select(0), + ), + ), + Expanded( + child: _NavButton( + icon: Icons.checklist_outlined, + selectedIcon: Icons.checklist, + label: 'Lists', + selected: _index == 1, + onTap: () => _select(1), + ), + ), + ], + ), + ), + const SizedBox(width: _fabGap), + Expanded( + child: _NavButton( + icon: Icons.receipt_long_outlined, + selectedIcon: Icons.receipt_long, + label: 'Shop log', + selected: _index == _shopLogIndex, + onTap: () => _select(_shopLogIndex), + ), + ), + ], + ), + ), + ); + } +} + +class _NavButton extends StatelessWidget { + const _NavButton({ + required this.icon, + required this.selectedIcon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final IconData selectedIcon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final color = selected ? scheme.primary : scheme.onSurfaceVariant; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(selected ? selectedIcon : icon, color: color, size: 20), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: color, + height: 1.1, + fontSize: 10, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), ), ], ), diff --git a/lib/models/shop.dart b/lib/models/shop.dart index 3e44f2f..c819ba0 100644 --- a/lib/models/shop.dart +++ b/lib/models/shop.dart @@ -8,22 +8,47 @@ class Shop { required this.shoppedAt, required this.total, required this.itemCount, + this.name, this.receiptImagePath, + this.tags = const [], }); final int id; final String supermarket; + final String? name; final DateTime shoppedAt; final double total; final int itemCount; final String? receiptImagePath; + final List tags; + + String get displayName { + final tripName = name?.trim(); + if (tripName != null && tripName.isNotEmpty) return tripName; + return supermarket; + } + + Shop copyWith({String? name, bool clearName = false, List? tags}) { + return Shop( + id: id, + supermarket: supermarket, + shoppedAt: shoppedAt, + total: total, + itemCount: itemCount, + receiptImagePath: receiptImagePath, + name: clearName ? null : (name ?? this.name), + tags: tags ?? this.tags, + ); + } factory Shop.fromMap(Map map) { + final rawName = (map['name'] as String?)?.trim(); return Shop( id: map['id']! as int, supermarket: map['supermarket']! as String, + name: (rawName == null || rawName.isEmpty) ? null : rawName, shoppedAt: DateTime.parse(map['shopped_at']! as String), - total: (map['total']! as num).toDouble(), + total: (map['total'] as num).toDouble(), itemCount: (map['item_count'] as num?)?.toInt() ?? 0, receiptImagePath: map['receipt_image_path'] as String?, ); @@ -55,7 +80,7 @@ class ShopItem { shopId: map['shop_id']! as int, productId: map['product_id']! as int, name: map['name']! as String, - price: (map['price']! as num).toDouble(), + price: (map['price'] as num).toDouble(), quantity: map['quantity'] as int?, unitPrice: (map['unit_price'] as num?)?.toDouble(), ); diff --git a/lib/review_screen.dart b/lib/review_screen.dart index 026942e..f376f45 100644 --- a/lib/review_screen.dart +++ b/lib/review_screen.dart @@ -11,6 +11,7 @@ import 'utils/money.dart'; import 'utils/supermarkets.dart'; import 'widgets/item_edit_sheet.dart'; import 'widgets/store_tag.dart'; +import 'widgets/tag_editor.dart'; class ReviewScreen extends StatefulWidget { const ReviewScreen({ @@ -42,6 +43,9 @@ class _ReviewScreenState extends State { String? _supermarket; String? _customStore; bool _saving = false; + final _tripName = TextEditingController(); + List _tags = []; + List _tagSuggestions = []; @override void initState() { @@ -57,6 +61,18 @@ class _ReviewScreenState extends State { } else if (widget.detectedStore != null) { _supermarket = widget.detectedStore; } + widget.repository + .getUsedShopTags() + .then((tags) { + if (mounted) setState(() => _tagSuggestions = tags); + }) + .catchError((_) {}); + } + + @override + void dispose() { + _tripName.dispose(); + super.dispose(); } String? get _resolvedSupermarket { @@ -117,6 +133,8 @@ class _ReviewScreenState extends State { shoppedAt: _shoppedAt, items: _items, receiptImagePath: widget.imagePath, + name: _tripName.text, + tags: _tags, ); if (!mounted) return; Navigator.of(context).pop(true); @@ -214,52 +232,88 @@ class _ReviewScreenState extends State { ), ), Expanded( - child: _items.isEmpty - ? Center( - child: Text( - 'No line items yet.\nAdd products for this trip.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _tripName, + enabled: !_saving, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Trip name', + hintText: 'Optional, e.g. Weekly shop', + ), + ), + const SizedBox(height: 12), + TagEditor( + tags: _tags, + suggestions: _tagSuggestions, + enabled: !_saving, + onChanged: (tags) => setState(() => _tags = tags), + ), + ], + ), + ), + ), + if (_items.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'No line items yet.\nAdd products for this trip.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), ), ) - : ListView.separated( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - itemCount: _items.length, - separatorBuilder: (_, _) => const SizedBox(height: 4), - itemBuilder: (context, index) { - final item = _items[index]; - return Dismissible( - key: ValueKey(item.id), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(12), + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverList.separated( + itemCount: _items.length, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final item = _items[index]; + return Dismissible( + key: ValueKey(item.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: theme.colorScheme.onErrorContainer, + ), ), - child: Icon( - Icons.delete_outline, - color: theme.colorScheme.onErrorContainer, + onDismissed: (_) { + setState(() { + _items.removeWhere((row) => row.id == item.id); + }); + }, + child: ListTile( + title: Text(item.name), + subtitle: _itemSubtitle(item), + trailing: Text( + formatEuro(item.price), + style: theme.textTheme.titleMedium, + ), + onTap: _saving ? null : () => _edit(existing: item), ), - ), - onDismissed: (_) { - setState(() { - _items.removeWhere((row) => row.id == item.id); - }); - }, - child: ListTile( - title: Text(item.name), - subtitle: _itemSubtitle(item), - trailing: Text( - formatEuro(item.price), - style: theme.textTheme.titleMedium, - ), - onTap: _saving ? null : () => _edit(existing: item), - ), - ); - }, + ); + }, + ), ), + ], + ), ), if (widget.rawText.isNotEmpty) ExpansionTile( diff --git a/lib/services/backup_format.dart b/lib/services/backup_format.dart index 6b6d4fe..b2712c3 100644 --- a/lib/services/backup_format.dart +++ b/lib/services/backup_format.dart @@ -3,7 +3,7 @@ library; import 'dart:convert'; -const int kBackupVersion = 2; +const int kBackupVersion = 3; class BackupDocument { const BackupDocument({ @@ -17,6 +17,7 @@ class BackupDocument { required this.shoppingLists, required this.shoppingListItems, required this.images, + this.shopTags = const [], }); final int version; @@ -26,6 +27,7 @@ class BackupDocument { final List> products; final List> shops; final List> shopItems; + final List> shopTags; final List> shoppingLists; final List> shoppingListItems; final Map images; @@ -39,6 +41,7 @@ class BackupDocument { 'products': products, 'shops': shops, 'shopItems': shopItems, + 'shopTags': shopTags, 'shoppingLists': shoppingLists, 'shoppingListItems': shoppingListItems, 'images': images, @@ -66,6 +69,7 @@ class BackupDocument { products: _maps(decoded['products']), shops: _maps(decoded['shops']), shopItems: _maps(decoded['shopItems']), + shopTags: _maps(decoded['shopTags']), shoppingLists: _maps(decoded['shoppingLists']), shoppingListItems: _maps(decoded['shoppingListItems']), images: _strings(decoded['images']), diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index 496b22b..fb0ac65 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -57,6 +57,7 @@ class BackupService { products: rewrittenProducts, shops: rewrittenShops, shopItems: await repository.dumpTable('shop_items'), + shopTags: await repository.dumpTable('shop_tags'), shoppingLists: await repository.dumpTable('shopping_lists'), shoppingListItems: await repository.dumpTable('shopping_list_items'), images: images, @@ -146,6 +147,7 @@ class BackupService { products: products, shops: shops, shopItems: document.shopItems, + shopTags: document.shopTags, shoppingLists: document.shoppingLists, shoppingListItems: document.shoppingListItems, darkMode: document.darkMode, diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index 8466871..136f234 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -33,7 +33,7 @@ class ProductRepository { p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); return openDatabase( path, - version: 5, + version: 6, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys = ON'); }, @@ -64,6 +64,9 @@ class ProductRepository { if (oldVersion < 5) { await _createShoppingListTables(db); } + if (oldVersion < 6) { + await _upgradeShopsToV6(db); + } }, ); } @@ -121,6 +124,7 @@ class ProductRepository { CREATE TABLE shops ( id INTEGER PRIMARY KEY AUTOINCREMENT, supermarket TEXT NOT NULL, + name TEXT, shopped_at TEXT NOT NULL, receipt_image_path TEXT, total REAL NOT NULL, @@ -140,6 +144,29 @@ class ProductRepository { FOREIGN KEY (product_id) REFERENCES products(id) ) '''); + await _createShopTagTable(db); + } + + Future _createShopTagTable(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS shop_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shop_id INTEGER NOT NULL, + tag TEXT NOT NULL, + normalized_tag TEXT NOT NULL, + FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE, + UNIQUE (shop_id, normalized_tag) + ) + '''); + } + + Future _upgradeShopsToV6(DatabaseExecutor db) async { + final columns = await db.rawQuery('PRAGMA table_info(shops)'); + final names = {for (final row in columns) row['name'] as String}; + if (!names.contains('name')) { + await db.execute('ALTER TABLE shops ADD COLUMN name TEXT'); + } + await _createShopTagTable(db); } Future _createShoppingListTables(DatabaseExecutor db) async { @@ -185,20 +212,25 @@ class ProductRepository { required DateTime shoppedAt, required List items, String? receiptImagePath, + String? name, + List tags = const [], }) async { final db = await _database; final now = DateTime.now().toIso8601String(); final total = roundMoney( items.fold(0, (sum, item) => sum + item.price), ); + final tripName = _optionalText(name); final shopId = await db.transaction((txn) async { final id = await txn.insert('shops', { 'supermarket': supermarket, + 'name': tripName, 'shopped_at': shoppedAt.toIso8601String(), 'total': total, 'created_at': now, }); + await _replaceShopTags(txn, shopId: id, tags: tags); for (final item in items) { final productId = await _upsertProduct( @@ -440,6 +472,7 @@ class ProductRepository { SELECT shops.id, shops.supermarket, + shops.name, shops.shopped_at, shops.receipt_image_path, shops.total, @@ -449,7 +482,7 @@ class ProductRepository { GROUP BY shops.id ORDER BY shops.shopped_at DESC, shops.id DESC '''); - return rows.map(Shop.fromMap).toList(); + return _shopsWithTags(rows); } Future getShop(int id) async { @@ -459,6 +492,7 @@ class ProductRepository { SELECT shops.id, shops.supermarket, + shops.name, shops.shopped_at, shops.receipt_image_path, shops.total, @@ -471,7 +505,103 @@ class ProductRepository { [id], ); if (rows.isEmpty) return null; - return Shop.fromMap(rows.first); + final shops = await _shopsWithTags(rows); + return shops.first; + } + + Future> _shopsWithTags(List> rows) async { + final shops = rows.map(Shop.fromMap).toList(); + if (shops.isEmpty) return shops; + final tags = await _tagsByShopIds(shops.map((shop) => shop.id).toList()); + return [ + for (final shop in shops) shop.copyWith(tags: tags[shop.id] ?? const []), + ]; + } + + Future>> _tagsByShopIds(List ids) async { + if (ids.isEmpty) return {}; + final db = await _database; + final placeholders = List.filled(ids.length, '?').join(','); + final rows = await db.query( + 'shop_tags', + where: 'shop_id IN ($placeholders)', + whereArgs: ids, + orderBy: 'tag COLLATE NOCASE ASC', + ); + final tags = >{}; + for (final row in rows) { + final shopId = row['shop_id']! as int; + (tags[shopId] ??= []).add(row['tag']! as String); + } + return tags; + } + + Future>> getShopItemNamesByShop() async { + final db = await _database; + final rows = await db.query('shop_items', columns: ['shop_id', 'name']); + final names = >{}; + for (final row in rows) { + (names[row['shop_id']! as int] ??= []).add(row['name']! as String); + } + return names; + } + + Future> getUsedShopTags() async { + final db = await _database; + final rows = await db.rawQuery(''' + SELECT tag FROM shop_tags + GROUP BY normalized_tag + ORDER BY tag COLLATE NOCASE ASC + '''); + return [for (final row in rows) row['tag']! as String]; + } + + Future updateShop({ + required int id, + String? name, + bool clearName = false, + List? tags, + }) async { + final db = await _database; + await db.transaction((txn) async { + if (clearName || name != null) { + await txn.update( + 'shops', + {'name': clearName ? null : _optionalText(name)}, + where: 'id = ?', + whereArgs: [id], + ); + } + if (tags != null) { + await _replaceShopTags(txn, shopId: id, tags: tags); + } + }); + } + + Future _replaceShopTags( + DatabaseExecutor db, { + required int shopId, + required List tags, + }) async { + await db.delete('shop_tags', where: 'shop_id = ?', whereArgs: [shopId]); + final seen = {}; + for (final raw in tags) { + final label = raw.trim().replaceAll(RegExp(r'\s+'), ' '); + if (label.isEmpty) continue; + final key = label.toLowerCase(); + if (!seen.add(key)) continue; + await db.insert('shop_tags', { + 'shop_id': shopId, + 'tag': label, + 'normalized_tag': key, + }); + } + } + + String? _optionalText(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + return trimmed; } Future> getShopItems(int shopId) async { @@ -491,6 +621,7 @@ class ProductRepository { final db = await _database; await db.transaction((txn) async { await txn.delete('shop_items', where: 'shop_id = ?', whereArgs: [id]); + await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]); await txn.delete('shops', where: 'id = ?', whereArgs: [id]); }); final path = shop?.receiptImagePath; @@ -845,11 +976,13 @@ class ProductRepository { required bool darkMode, List> shoppingLists = const [], List> shoppingListItems = const [], + List> shopTags = const [], }) async { final db = await _database; await db.transaction((txn) async { await txn.delete('shopping_list_items'); await txn.delete('shopping_lists'); + await txn.delete('shop_tags'); await txn.delete('shop_items'); await txn.delete('shops'); await txn.delete('products'); @@ -868,6 +1001,9 @@ class ProductRepository { for (final row in shopItems) { await txn.insert('shop_items', _sqlRow(row, _shopItemColumns)); } + for (final row in shopTags) { + await txn.insert('shop_tags', _sqlRow(row, _shopTagColumns)); + } for (final row in shoppingLists) { await txn.insert('shopping_lists', _sqlRow(row, _shoppingListColumns)); } @@ -886,6 +1022,7 @@ class ProductRepository { await _resetSequence(txn, 'products'); await _resetSequence(txn, 'shops'); await _resetSequence(txn, 'shop_items'); + await _resetSequence(txn, 'shop_tags'); await _resetSequence(txn, 'shopping_lists'); await _resetSequence(txn, 'shopping_list_items'); }); @@ -909,6 +1046,7 @@ class ProductRepository { static const _shopColumns = { 'id', 'supermarket', + 'name', 'shopped_at', 'receipt_image_path', 'total', @@ -923,6 +1061,7 @@ class ProductRepository { 'quantity', 'unit_price', }; + static const _shopTagColumns = {'id', 'shop_id', 'tag', 'normalized_tag'}; static const _shoppingListColumns = { 'id', 'name', diff --git a/lib/shop_detail_screen.dart b/lib/shop_detail_screen.dart index c622bce..658ad7e 100644 --- a/lib/shop_detail_screen.dart +++ b/lib/shop_detail_screen.dart @@ -13,6 +13,8 @@ import 'utils/dates.dart'; import 'utils/money.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/item_edit_sheet.dart'; +import 'widgets/name_dialog.dart'; +import 'widgets/tag_editor.dart'; class ShopDetailScreen extends StatefulWidget { const ShopDetailScreen({ @@ -31,11 +33,18 @@ class ShopDetailScreen extends StatefulWidget { class _ShopDetailScreenState extends State { late Future<({Shop shop, List items})> _future; bool _editing = false; + List _tagSuggestions = []; @override void initState() { super.initState(); _future = _load(); + widget.repository + .getUsedShopTags() + .then((tags) { + if (mounted) setState(() => _tagSuggestions = tags); + }) + .catchError((_) {}); } Future<({Shop shop, List items})> _load() async { @@ -111,6 +120,28 @@ class _ShopDetailScreenState extends State { if (mounted) _reload(); } + Future _editName(Shop shop) async { + final name = await showNameDialog( + context, + title: 'Trip name', + label: 'Name', + initial: shop.name, + allowEmpty: true, + ); + if (name == null || !mounted) return; + await widget.repository.updateShop( + id: shop.id, + name: name, + clearName: name.isEmpty, + ); + if (mounted) _reload(); + } + + Future _setTags(Shop shop, List tags) async { + await widget.repository.updateShop(id: shop.id, tags: tags); + if (mounted) _reload(); + } + Widget? _itemSubtitle(ShopItem item) { final parts = [ if (item.quantity != null) @@ -160,12 +191,40 @@ class _ShopDetailScreenState extends State { return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ - Text(shop.supermarket, style: theme.textTheme.headlineMedium), + if (_editing) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + shop.name?.isNotEmpty == true + ? shop.name! + : 'Add a trip name', + ), + subtitle: const Text('Optional name for this shop log'), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _editName(shop), + ) + else + Text(shop.displayName, style: theme.textTheme.headlineMedium), + if (_editing || (shop.name != null && shop.name!.isNotEmpty)) ...[ + const SizedBox(height: 4), + Text(shop.supermarket, style: theme.textTheme.titleMedium), + ], const SizedBox(height: 4), Text( formatDate(shop.shoppedAt), style: theme.textTheme.bodyLarge, ), + if (_editing) ...[ + const SizedBox(height: 16), + TagEditor( + tags: shop.tags, + suggestions: _tagSuggestions, + onChanged: (tags) => _setTags(shop, tags), + ), + ] else if (shop.tags.isNotEmpty) ...[ + const SizedBox(height: 12), + TagChips(tags: shop.tags), + ], if (hasReceipt) ...[ const SizedBox(height: 16), GestureDetector( diff --git a/lib/shop_log_screen.dart b/lib/shop_log_screen.dart index c0f2beb..debb8d3 100644 --- a/lib/shop_log_screen.dart +++ b/lib/shop_log_screen.dart @@ -1,19 +1,22 @@ -/// Log of saved shopping trips. +/// Log of saved shopping trips, with a monthly heatmap and product search. library; -import 'dart:io'; - import 'package:flutter/material.dart'; import 'models/shop.dart'; +import 'models/supermarket.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'shop_detail_screen.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; +import 'utils/shop_calendar.dart'; +import 'utils/shop_log_filter.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/settings_button.dart'; +import 'widgets/shop_heatmap.dart'; import 'widgets/store_tag.dart'; +import 'widgets/tag_editor.dart'; class ShopLogScreen extends StatefulWidget { const ShopLogScreen({super.key, required this.repository}); @@ -25,17 +28,37 @@ class ShopLogScreen extends StatefulWidget { } class _ShopLogScreenState extends State { - late Future> _future; + late Future<({List shops, Map> productNames})> + _future; + final _search = TextEditingController(); bool _editing = false; + bool _searching = false; + DateTime _month = monthStart(DateTime.now()); + DateTime? _selectedDay; + String? _tagFilter; @override void initState() { super.initState(); - _future = widget.repository.getShops(); + _future = _load(); + _search.addListener(() => setState(() {})); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + Future<({List shops, Map> productNames})> + _load() async { + final shops = await widget.repository.getShops(); + final productNames = await widget.repository.getShopItemNamesByShop(); + return (shops: shops, productNames: productNames); } void _reload() { - setState(() => _future = widget.repository.getShops()); + setState(() => _future = _load()); } Future _openShop(Shop shop) async { @@ -59,15 +82,52 @@ class _ShopLogScreenState extends State { if (mounted) _reload(); } + void _selectDay(DateTime? day) { + setState(() => _selectedDay = day); + } + + void _goMonth(int delta) { + final next = addMonths(_month, delta); + final current = monthStart(DateTime.now()); + if (delta > 0 && !next.isBefore(current) && next != current) return; + setState(() { + _month = next.isAfter(current) ? current : next; + _selectedDay = null; + }); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; + final nowMonth = monthStart(DateTime.now()); return Scaffold( appBar: AppBar( - title: const Text('Shop log'), + title: _searching + ? TextField( + controller: _search, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search products in trips', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + ), + ) + : const Text('Shop log'), actions: [ + IconButton( + tooltip: _searching ? 'Close search' : 'Search products', + onPressed: () { + setState(() { + _searching = !_searching; + if (!_searching) _search.clear(); + }); + }, + icon: Icon(_searching ? Icons.close : Icons.search), + ), IconButton( tooltip: _editing ? 'Done' : 'Edit', onPressed: () => setState(() => _editing = !_editing), @@ -76,7 +136,7 @@ class _ShopLogScreenState extends State { const SettingsButton(), ], ), - body: FutureBuilder>( + body: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.hasError) { @@ -91,49 +151,104 @@ class _ShopLogScreenState extends State { return const Center(child: CircularProgressIndicator()); } - final shops = snapshot.data!; - if (shops.isEmpty) { - return Center( - child: Text( - 'Scan a receipt or enter a trip to log it.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge, - ), - ); - } + final shops = snapshot.data!.shops; + final productNames = snapshot.data!.productNames; + final counts = tripCountsByDay([ + for (final shop in shops) + if (isSameMonth(shop.shoppedAt, _month)) shop.shoppedAt, + ]); + final monthShops = [ + for (final shop in shops) + if (isSameMonth(shop.shoppedAt, _month)) shop, + ]; + final monthTotal = monthShops.fold( + 0, + (sum, shop) => sum + shop.total, + ); + final visible = filterShops( + shops: shops, + productNames: productNames, + selectedDay: _selectedDay, + tag: _tagFilter, + query: _search.text, + ); - return ListView.separated( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), - itemCount: shops.length, - separatorBuilder: (_, _) => const SizedBox(height: 4), - itemBuilder: (context, index) { - final shop = shops[index]; - final count = shop.itemCount; - return ListTile( - leading: _ReceiptThumb(path: shop.receiptImagePath), - title: Align( - alignment: Alignment.centerLeft, - child: StoreTag(name: shop.supermarket, stores: stores), + return ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + ShopHeatmap( + month: _month, + counts: counts, + monthTotal: monthTotal, + monthTripCount: monthShops.length, + canGoForward: _month.isBefore(nowMonth), + selectedDay: _selectedDay, + onPreviousMonth: () => _goMonth(-1), + onNextMonth: () => _goMonth(1), + onSelectDay: _selectDay, + ), + if (_selectedDay != null || _tagFilter != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (_selectedDay != null) + InputChip( + label: Text(formatDate(_selectedDay!)), + onDeleted: () => _selectDay(null), + ), + if (_tagFilter != null) + InputChip( + label: Text(_tagFilter!), + onDeleted: () => setState(() => _tagFilter = null), + ), + ], + ), ), - subtitle: Text( - [ - formatDate(shop.shoppedAt), - count == 1 ? '1 product' : '$count products', - ].join(' · '), - ), - trailing: _editing - ? IconButton( - tooltip: 'Remove', - onPressed: () => _removeShop(shop), - icon: const Icon(Icons.delete_outline), - ) - : Text( - formatEuro(shop.total), - style: theme.textTheme.titleMedium, - ), - onTap: _editing ? null : () => _openShop(shop), - ); - }, + if (shops.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Scan a receipt or enter a trip to log it.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ) + else if (visible.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text( + _search.text.trim().isNotEmpty + ? 'No trips contain that product.' + : 'No trips match this filter.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ) + else + for (final shop in visible) + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), + child: _ShopLogTile( + shop: shop, + stores: stores, + editing: _editing, + selectedTag: _tagFilter, + onOpen: () => _openShop(shop), + onRemove: () => _removeShop(shop), + onTagTap: (tag) { + setState(() { + _tagFilter = + _tagFilter?.toLowerCase() == tag.toLowerCase() + ? null + : tag; + }); + }, + ), + ), + ], ); }, ), @@ -141,42 +256,92 @@ class _ShopLogScreenState extends State { } } -class _ReceiptThumb extends StatelessWidget { - const _ReceiptThumb({this.path}); +class _ShopLogTile extends StatelessWidget { + const _ShopLogTile({ + required this.shop, + required this.stores, + required this.editing, + required this.onOpen, + required this.onRemove, + required this.onTagTap, + this.selectedTag, + }); - final String? path; + final Shop shop; + final List stores; + final bool editing; + final VoidCallback onOpen; + final VoidCallback onRemove; + final ValueChanged onTagTap; + final String? selectedTag; @override Widget build(BuildContext context) { - final file = path == null ? null : File(path!); - final hasFile = file != null && file.existsSync(); - final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final count = shop.itemCount; + final hasName = shop.name != null && shop.name!.isNotEmpty; + final meta = [ + formatDate(shop.shoppedAt), + count == 1 ? '1 product' : '$count products', + ].join(' · '); - return ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SizedBox( - width: 48, - height: 48, - child: hasFile - ? Image.file( - file, - fit: BoxFit.cover, - cacheWidth: 144, - errorBuilder: (_, _, _) => ColoredBox( - color: scheme.secondaryContainer, - child: Icon( - Icons.receipt_long_outlined, - color: scheme.onSecondaryContainer, - ), - ), - ) - : ColoredBox( - color: scheme.secondaryContainer, - child: Icon( - Icons.receipt_long_outlined, - color: scheme.onSecondaryContainer, + return Material( + color: Colors.transparent, + child: InkWell( + onTap: editing ? null : onOpen, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasName) + Text(shop.name!, style: theme.textTheme.titleMedium) + else + StoreTag(name: shop.supermarket, stores: stores), + const SizedBox(height: 4), + if (hasName) ...[ + StoreTag( + name: shop.supermarket, + stores: stores, + compact: true, + ), + const SizedBox(height: 4), + ], + Text(meta, style: theme.textTheme.bodyMedium), + if (shop.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + TagChips( + tags: shop.tags, + compact: true, + selected: selectedTag, + onTap: onTagTap, + ), + ], + ], ), ), + const SizedBox(width: 8), + editing + ? IconButton( + tooltip: 'Remove', + onPressed: onRemove, + icon: const Icon(Icons.delete_outline), + ) + : Padding( + padding: const EdgeInsets.only(top: 8, right: 8), + child: Text( + formatEuro(shop.total), + style: theme.textTheme.titleMedium, + ), + ), + ], + ), + ), ), ); } diff --git a/lib/utils/dates.dart b/lib/utils/dates.dart index fed2a02..52d1ff9 100644 --- a/lib/utils/dates.dart +++ b/lib/utils/dates.dart @@ -16,5 +16,23 @@ const _months = [ 'Dec', ]; +const _monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + String formatDate(DateTime date) => '${date.day} ${_months[date.month - 1]} ${date.year}'; + +String formatMonthYear(DateTime date) => + '${_monthNames[date.month - 1]} ${date.year}'; diff --git a/lib/utils/shop_calendar.dart b/lib/utils/shop_calendar.dart new file mode 100644 index 0000000..57a62f6 --- /dev/null +++ b/lib/utils/shop_calendar.dart @@ -0,0 +1,48 @@ +/// Month-grid helpers for the shop-log heatmap. +library; + +DateTime dateOnly(DateTime date) => DateTime(date.year, date.month, date.day); + +DateTime monthStart(DateTime date) => DateTime(date.year, date.month); + +bool isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + +bool isSameMonth(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month; + +DateTime addMonths(DateTime month, int delta) => + DateTime(month.year, month.month + delta); + +/// Monday-first calendar cells for [month], padded with nulls to full weeks. +List monthGrid(DateTime month) { + final start = DateTime(month.year, month.month, 1); + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + final leading = start.weekday - DateTime.monday; + final cells = [ + ...List.filled(leading, null), + for (var day = 1; day <= daysInMonth; day++) + DateTime(month.year, month.month, day), + ]; + while (cells.length % 7 != 0) { + cells.add(null); + } + return cells; +} + +/// How many trips landed on each calendar day. +Map tripCountsByDay(Iterable shoppedAt) { + final counts = {}; + for (final date in shoppedAt) { + final key = dateOnly(date); + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +/// GitHub-style 0–4 intensity from a trip count. +int heatmapLevel(int count) { + if (count <= 0) return 0; + if (count >= 4) return 4; + return count; +} diff --git a/lib/utils/shop_log_filter.dart b/lib/utils/shop_log_filter.dart new file mode 100644 index 0000000..ae9e114 --- /dev/null +++ b/lib/utils/shop_log_filter.dart @@ -0,0 +1,48 @@ +/// Filters shop-log rows by day, tag, and product search. +library; + +import '../models/shop.dart'; +import 'fuzzy.dart'; +import 'shop_calendar.dart'; + +List filterShops({ + required List shops, + required Map> productNames, + DateTime? selectedDay, + String? tag, + String query = '', +}) { + final needle = query.trim(); + final tagFilter = tag?.trim(); + return [ + for (final shop in shops) + if (_matches( + shop: shop, + names: productNames[shop.id] ?? const [], + selectedDay: selectedDay, + tag: tagFilter, + query: needle, + )) + shop, + ]; +} + +bool _matches({ + required Shop shop, + required List names, + required DateTime? selectedDay, + required String? tag, + required String query, +}) { + if (selectedDay != null && !isSameDay(shop.shoppedAt, selectedDay)) { + return false; + } + if (tag != null && tag.isNotEmpty) { + final wanted = tag.toLowerCase(); + if (!shop.tags.any((item) => item.toLowerCase() == wanted)) { + return false; + } + } + if (query.isEmpty) return true; + return names.any((name) => fuzzyScore(query, name) != null); +} diff --git a/lib/widgets/name_dialog.dart b/lib/widgets/name_dialog.dart index 194f037..d012987 100644 --- a/lib/widgets/name_dialog.dart +++ b/lib/widgets/name_dialog.dart @@ -9,6 +9,7 @@ Future showNameDialog( String? initial, String label = 'Name', String confirmLabel = 'Save', + bool allowEmpty = false, }) { return showDialog( context: context, @@ -17,6 +18,7 @@ Future showNameDialog( initial: initial ?? '', label: label, confirmLabel: confirmLabel, + allowEmpty: allowEmpty, ), ); } @@ -27,12 +29,14 @@ class _NameDialog extends StatefulWidget { required this.initial, required this.label, required this.confirmLabel, + required this.allowEmpty, }); final String title; final String initial; final String label; final String confirmLabel; + final bool allowEmpty; @override State<_NameDialog> createState() => _NameDialogState(); @@ -56,7 +60,7 @@ class _NameDialogState extends State<_NameDialog> { void _save() { final name = _controller.text.trim(); - if (name.isEmpty) { + if (name.isEmpty && !widget.allowEmpty) { setState(() => _error = 'Enter a name.'); return; } diff --git a/lib/widgets/shop_heatmap.dart b/lib/widgets/shop_heatmap.dart new file mode 100644 index 0000000..d2706df --- /dev/null +++ b/lib/widgets/shop_heatmap.dart @@ -0,0 +1,216 @@ +/// GitHub-style contribution heatmap for one calendar month of shopping trips. +library; + +import 'package:flutter/material.dart'; + +import '../utils/dates.dart'; +import '../utils/money.dart'; +import '../utils/shop_calendar.dart'; + +class ShopHeatmap extends StatelessWidget { + const ShopHeatmap({ + super.key, + required this.month, + required this.counts, + required this.monthTotal, + required this.monthTripCount, + required this.canGoForward, + this.selectedDay, + required this.onPreviousMonth, + required this.onNextMonth, + required this.onSelectDay, + }); + + final DateTime month; + final Map counts; + final double monthTotal; + final int monthTripCount; + final bool canGoForward; + final DateTime? selectedDay; + final VoidCallback onPreviousMonth; + final VoidCallback onNextMonth; + final ValueChanged onSelectDay; + + static const _weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final cells = monthGrid(month); + final today = dateOnly(DateTime.now()); + final weeks = cells.length ~/ 7; + + return Card( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + IconButton( + tooltip: 'Previous month', + onPressed: onPreviousMonth, + icon: const Icon(Icons.chevron_left), + ), + Expanded( + child: Text( + formatMonthYear(month), + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + ), + IconButton( + tooltip: 'Next month', + onPressed: canGoForward ? onNextMonth : null, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + Text( + monthTripCount == 0 + ? 'No trips this month' + : '${monthTripCount == 1 ? '1 trip' : '$monthTripCount trips'} · ${formatEuro(monthTotal)}', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Row( + children: [ + for (final label in _weekdays) + Expanded( + child: Text( + label, + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium, + ), + ), + ], + ), + const SizedBox(height: 4), + for (var week = 0; week < weeks; week++) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + for (var weekday = 0; weekday < 7; weekday++) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _HeatCell( + date: cells[week * 7 + weekday], + count: cells[week * 7 + weekday] == null + ? 0 + : counts[cells[week * 7 + weekday]] ?? 0, + today: today, + selectedDay: selectedDay, + onSelect: onSelectDay, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Text('Less', style: theme.textTheme.labelMedium), + const SizedBox(width: 6), + for (var level = 0; level <= 4; level++) + Padding( + padding: const EdgeInsets.only(right: 3), + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: _fillForLevel(scheme, level), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + Text('More', style: theme.textTheme.labelMedium), + ], + ), + ], + ), + ), + ); + } +} + +class _HeatCell extends StatelessWidget { + const _HeatCell({ + required this.date, + required this.count, + required this.today, + required this.selectedDay, + required this.onSelect, + }); + + final DateTime? date; + final int count; + final DateTime today; + final DateTime? selectedDay; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + if (date == null) { + return const SizedBox(height: 36); + } + + final scheme = Theme.of(context).colorScheme; + final level = heatmapLevel(count); + final selected = selectedDay != null && isSameDay(date!, selectedDay!); + final isToday = isSameDay(date!, today); + final fill = _fillForLevel(scheme, level); + final textColor = level >= 3 + ? scheme.onPrimary + : scheme.onSurface.withValues(alpha: count == 0 ? 0.45 : 1); + + return Tooltip( + message: count == 0 + ? formatDate(date!) + : '${formatDate(date!)} · ${count == 1 ? '1 trip' : '$count trips'}', + child: InkWell( + onTap: () => onSelect(selected ? null : date), + borderRadius: BorderRadius.circular(6), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: selected + ? scheme.primary + : isToday + ? scheme.outline + : Colors.transparent, + width: selected ? 2 : 1, + ), + ), + child: Text( + '${date!.day}', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: textColor, + fontWeight: isToday || selected + ? FontWeight.w700 + : FontWeight.w500, + fontSize: 11, + ), + ), + ), + ), + ); + } +} + +Color _fillForLevel(ColorScheme scheme, int level) { + if (level <= 0) return scheme.surfaceContainerHighest; + final t = (level / 4).clamp(0.28, 1.0); + return Color.lerp(scheme.primary.withValues(alpha: 0.22), scheme.primary, t)!; +} diff --git a/lib/widgets/tag_editor.dart b/lib/widgets/tag_editor.dart new file mode 100644 index 0000000..55862ca --- /dev/null +++ b/lib/widgets/tag_editor.dart @@ -0,0 +1,181 @@ +/// Chip row and optional editor for shopping-trip tags. +library; + +import 'package:flutter/material.dart'; + +class TagChips extends StatelessWidget { + const TagChips({ + super.key, + required this.tags, + this.onTap, + this.selected, + this.compact = false, + }); + + final List tags; + final ValueChanged? onTap; + final String? selected; + final bool compact; + + @override + Widget build(BuildContext context) { + if (tags.isEmpty) return const SizedBox.shrink(); + return Wrap( + spacing: compact ? 4 : 6, + runSpacing: compact ? 4 : 6, + children: [ + for (final tag in tags) + _TagChip( + label: tag, + selected: + selected != null && + selected!.toLowerCase() == tag.toLowerCase(), + compact: compact, + onPressed: onTap == null ? null : () => onTap!(tag), + ), + ], + ); + } +} + +class TagEditor extends StatefulWidget { + const TagEditor({ + super.key, + required this.tags, + required this.onChanged, + this.suggestions = const [], + this.enabled = true, + }); + + final List tags; + final List suggestions; + final ValueChanged> onChanged; + final bool enabled; + + @override + State createState() => _TagEditorState(); +} + +class _TagEditorState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _add(String raw) { + final label = raw.trim().replaceAll(RegExp(r'\s+'), ' '); + if (label.isEmpty) return; + final exists = widget.tags.any( + (tag) => tag.toLowerCase() == label.toLowerCase(), + ); + if (exists) { + _controller.clear(); + return; + } + widget.onChanged([...widget.tags, label]); + _controller.clear(); + } + + void _remove(String tag) { + widget.onChanged([ + for (final item in widget.tags) + if (item.toLowerCase() != tag.toLowerCase()) item, + ]); + } + + @override + Widget build(BuildContext context) { + final unused = [ + for (final tag in widget.suggestions) + if (!widget.tags.any((item) => item.toLowerCase() == tag.toLowerCase())) + tag, + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.tags.isNotEmpty) ...[ + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final tag in widget.tags) + InputChip( + label: Text(tag), + onDeleted: widget.enabled ? () => _remove(tag) : null, + ), + ], + ), + const SizedBox(height: 8), + ], + TextField( + controller: _controller, + enabled: widget.enabled, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Add tag', + hintText: 'weekly, bulk, work…', + ), + onSubmitted: widget.enabled ? _add : null, + ), + if (widget.enabled && unused.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final tag in unused.take(12)) + ActionChip(label: Text(tag), onPressed: () => _add(tag)), + ], + ), + ], + ], + ); + } +} + +class _TagChip extends StatelessWidget { + const _TagChip({ + required this.label, + required this.selected, + required this.compact, + this.onPressed, + }); + + final String label; + final bool selected; + final bool compact; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final bg = selected ? scheme.primary : scheme.secondaryContainer; + final fg = selected ? scheme.onPrimary : scheme.onSecondaryContainer; + return Material( + color: bg, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: compact ? 6 : 8, + vertical: compact ? 2 : 4, + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: fg, + fontWeight: FontWeight.w600, + fontSize: compact ? 11 : 12, + ), + ), + ), + ), + ); + } +} diff --git a/test/backup_format_test.dart b/test/backup_format_test.dart index 2a196df..e7dbdef 100644 --- a/test/backup_format_test.dart +++ b/test/backup_format_test.dart @@ -4,7 +4,7 @@ import 'package:receipity/services/backup_format.dart'; void main() { test('round-trips a backup document', () { const original = BackupDocument( - version: 2, + version: 3, exportedAt: '2026-08-29T12:00:00.000', darkMode: true, supermarkets: [ @@ -29,6 +29,7 @@ void main() { { 'id': 8, 'supermarket': 'Jumbo', + 'name': 'Weekly shop', 'shopped_at': '2026-08-29T12:00:00.000', 'receipt_image_path': 'receipts/shop_8.jpg', 'total': 1.5, @@ -46,6 +47,9 @@ void main() { 'unit_price': 1.5, }, ], + shopTags: [ + {'id': 1, 'shop_id': 8, 'tag': 'weekly', 'normalized_tag': 'weekly'}, + ], shoppingLists: [ { 'id': 1, @@ -71,7 +75,9 @@ void main() { expect(parsed.darkMode, isTrue); expect(parsed.products.single['name'], 'Milk'); expect(parsed.shops.single['id'], 8); + expect(parsed.shops.single['name'], 'Weekly shop'); expect(parsed.shopItems.single['product_id'], 1); + expect(parsed.shopTags.single['tag'], 'weekly'); expect(parsed.shoppingLists.single['name'], 'Weekly'); expect(parsed.shoppingListItems.single['name'], 'Milk'); expect(parsed.images['products/product_1.jpg'], 'abc123'); @@ -95,6 +101,7 @@ void main() { expect(parsed.version, 1); expect(parsed.shoppingLists, isEmpty); expect(parsed.shoppingListItems, isEmpty); + expect(parsed.shopTags, isEmpty); }); test('rejects a file that is not a Receipity backup', () { diff --git a/test/shop_log_test.dart b/test/shop_log_test.dart new file mode 100644 index 0000000..7bd78b9 --- /dev/null +++ b/test/shop_log_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:receipity/models/shop.dart'; +import 'package:receipity/utils/shop_calendar.dart'; +import 'package:receipity/utils/shop_log_filter.dart'; + +Shop _shop({ + required int id, + required DateTime at, + String supermarket = 'Jumbo', + String? name, + List tags = const [], +}) { + return Shop( + id: id, + supermarket: supermarket, + name: name, + shoppedAt: at, + total: 10, + itemCount: 1, + tags: tags, + ); +} + +void main() { + group('monthGrid', () { + test('pads August 2026 from Saturday with Monday-first weeks', () { + final cells = monthGrid(DateTime(2026, 8)); + expect(cells.length % 7, 0); + expect(cells[0], isNull); + expect(cells[1], isNull); + expect(cells[2], isNull); + expect(cells[3], isNull); + expect(cells[4], isNull); + expect(cells[5], DateTime(2026, 8, 1)); + expect(cells[35], DateTime(2026, 8, 31)); + expect(cells.last, isNull); + }); + }); + + test('tripCountsByDay groups multiple trips on the same day', () { + final counts = tripCountsByDay([ + DateTime(2026, 8, 12, 9), + DateTime(2026, 8, 12, 18), + DateTime(2026, 8, 13), + ]); + expect(counts[DateTime(2026, 8, 12)], 2); + expect(counts[DateTime(2026, 8, 13)], 1); + expect(heatmapLevel(0), 0); + expect(heatmapLevel(1), 1); + expect(heatmapLevel(4), 4); + expect(heatmapLevel(9), 4); + }); + + group('filterShops', () { + final shops = [ + _shop(id: 1, at: DateTime(2026, 8, 12), tags: const ['weekly']), + _shop( + id: 2, + at: DateTime(2026, 8, 13), + name: 'Work lunch', + tags: const ['work'], + ), + ]; + const names = { + 1: ['Milk', 'Bread'], + 2: ['Coffee'], + }; + + test('filters by product name', () { + final found = filterShops( + shops: shops, + productNames: names, + query: 'milk', + ); + expect(found.map((shop) => shop.id), [1]); + }); + + test('filters by selected day', () { + final found = filterShops( + shops: shops, + productNames: names, + selectedDay: DateTime(2026, 8, 13), + ); + expect(found.single.id, 2); + }); + + test('filters by tag', () { + final found = filterShops( + shops: shops, + productNames: names, + tag: 'weekly', + ); + expect(found.single.id, 1); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index fab4fa3..6a7127d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,6 +4,7 @@ import 'package:receipity/main.dart'; import 'package:receipity/models/line_item.dart'; import 'package:receipity/review_screen.dart'; import 'package:receipity/services/product_repository.dart'; +import 'package:receipity/widgets/shop_heatmap.dart'; void main() { testWidgets('scan screen shows capture actions and bottom navigation', ( @@ -22,10 +23,41 @@ void main() { ); expect(find.text('Products'), findsOneWidget); expect(find.text('Lists'), findsOneWidget); - expect(find.text('Add'), findsOneWidget); + expect(find.byTooltip('Add'), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); expect(find.text('Shop log'), findsOneWidget); }); + testWidgets('shop log has product search', (tester) async { + await tester.pumpWidget(ReceipityApp()); + await tester.tap(find.text('Shop log')); + await tester.pump(); + expect(find.byTooltip('Search products'), findsOneWidget); + }); + + testWidgets('shop heatmap renders the visible month', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ShopHeatmap( + month: DateTime(2026, 8), + counts: const {}, + monthTotal: 0, + monthTripCount: 0, + canGoForward: false, + onPreviousMonth: () {}, + onNextMonth: () {}, + onSelectDay: (_) {}, + ), + ), + ), + ); + expect(find.text('August 2026'), findsOneWidget); + expect(find.text('No trips this month'), findsOneWidget); + expect(find.text('Less'), findsOneWidget); + expect(find.text('More'), findsOneWidget); + }); + testWidgets('enter manually opens a blank trip', (tester) async { await tester.pumpWidget(ReceipityApp()); await tester.tap(find.text('Enter manually')); @@ -60,6 +92,8 @@ void main() { expect(find.text('Add item'), findsOneWidget); expect(find.textContaining('Save trip'), findsOneWidget); expect(find.text('Supermarket'), findsOneWidget); + expect(find.text('Trip name'), findsOneWidget); + expect(find.text('Add tag'), findsOneWidget); await tester.drag(find.text('Milk'), const Offset(-500, 0)); await tester.pumpAndSettle();