diff --git a/lib/home_shell.dart b/lib/home_shell.dart new file mode 100644 index 0000000..9fcd5d6 --- /dev/null +++ b/lib/home_shell.dart @@ -0,0 +1,108 @@ +/// Bottom navigation: products, scan, and shop log. +library; + +import 'package:flutter/material.dart'; + +import 'products_screen.dart'; +import 'scan_screen.dart'; +import 'services/app_settings.dart'; +import 'services/product_repository.dart'; +import 'shop_log_screen.dart'; + +class HomeShell extends StatefulWidget { + const HomeShell({super.key, required this.repository}); + + final ProductRepository repository; + + @override + State createState() => _HomeShellState(); +} + +class _HomeShellState extends State { + static const _scanIndex = 1; + static const _shopLogIndex = 2; + + int _index = _scanIndex; + int _revision = 0; + AppSettings? _settings; + int _seenGeneration = 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final settings = SettingsScope.maybeOf(context); + if (settings != _settings) { + _settings?.removeListener(_onSettings); + _settings = settings; + _seenGeneration = settings?.catalogGeneration ?? 0; + _settings?.addListener(_onSettings); + } + } + + @override + void dispose() { + _settings?.removeListener(_onSettings); + super.dispose(); + } + + void _onSettings() { + final generation = _settings?.catalogGeneration ?? 0; + if (generation == _seenGeneration) return; + _seenGeneration = generation; + if (mounted) setState(() => _revision++); + } + + void _onTripSaved() { + setState(() { + _index = _shopLogIndex; + _revision++; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Shopping trip saved.')), + ); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: switch (_index) { + 0 => ProductsScreen( + key: ValueKey('products-$_revision'), + repository: widget.repository, + ), + 1 => ScanScreen( + repository: widget.repository, + onTripSaved: _onTripSaved, + ), + _ => ShopLogScreen( + key: ValueKey('shops-$_revision'), + 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.photo_camera_outlined), + selectedIcon: Icon(Icons.photo_camera), + label: 'Scan', + ), + NavigationDestination( + icon: Icon(Icons.receipt_long_outlined), + selectedIcon: Icon(Icons.receipt_long), + label: 'Shop log', + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index f1e188f..a3ff2a1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,8 @@ library; import 'package:flutter/material.dart'; -import 'scan_screen.dart'; +import 'home_shell.dart'; +import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'utils/app_theme.dart'; import 'utils/constants.dart'; @@ -13,20 +14,48 @@ void main() { runApp(ReceipityApp()); } -class ReceipityApp extends StatelessWidget { +class ReceipityApp extends StatefulWidget { ReceipityApp({super.key, ProductRepository? repository}) : repository = repository ?? ProductRepository(); final ProductRepository repository; + @override + State createState() => _ReceipityAppState(); +} + +class _ReceipityAppState extends State { + late final AppSettings _settings; + + @override + void initState() { + super.initState(); + _settings = AppSettings(widget.repository); + _settings.addListener(_onSettings); + _settings.load(); + } + + @override + void dispose() { + _settings.removeListener(_onSettings); + _settings.dispose(); + super.dispose(); + } + + void _onSettings() => setState(() {}); + @override Widget build(BuildContext context) { - return MaterialApp( - title: kAppName, - debugShowCheckedModeBanner: false, - theme: AppTheme.light(), - darkTheme: AppTheme.dark(), - home: ScanScreen(repository: repository), + return SettingsScope( + settings: _settings, + child: MaterialApp( + title: kAppName, + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: _settings.darkMode ? ThemeMode.dark : ThemeMode.light, + home: HomeShell(repository: widget.repository), + ), ); } } diff --git a/lib/models/product.dart b/lib/models/product.dart index fc7bff3..907a95a 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -8,6 +8,11 @@ class Product { required this.lastPrice, required this.timesSeen, required this.updatedAt, + this.favourite = false, + this.imagePath, + this.supermarket, + this.notes, + this.category, }); final int id; @@ -15,14 +20,56 @@ class Product { final double lastPrice; final int timesSeen; final DateTime updatedAt; + final bool favourite; + final String? imagePath; + final String? supermarket; + final String? notes; + final String? category; factory Product.fromMap(Map map) { return Product( id: map['id']! as int, name: map['name']! as String, - lastPrice: (map['last_price']! as num).toDouble(), + lastPrice: (map['display_price'] as num? ?? map['last_price']! as num) + .toDouble(), timesSeen: map['times_seen']! as int, updatedAt: DateTime.parse(map['updated_at']! as String), + favourite: (map['favourite'] as int? ?? 0) == 1, + imagePath: map['image_path'] as String?, + supermarket: map['supermarket'] as String?, + notes: map['notes'] as String?, + category: map['category'] as String?, + ); + } + + Product copyWith({ + String? name, + double? lastPrice, + int? timesSeen, + DateTime? updatedAt, + bool? favourite, + String? imagePath, + String? supermarket, + String? notes, + String? category, + bool clearImage = false, + bool clearSupermarket = false, + bool clearNotes = false, + bool clearCategory = false, + }) { + return Product( + id: id, + name: name ?? this.name, + lastPrice: lastPrice ?? this.lastPrice, + timesSeen: timesSeen ?? this.timesSeen, + updatedAt: updatedAt ?? this.updatedAt, + favourite: favourite ?? this.favourite, + imagePath: clearImage ? null : (imagePath ?? this.imagePath), + supermarket: clearSupermarket ? null : (supermarket ?? this.supermarket), + notes: clearNotes ? null : (notes ?? this.notes), + category: clearCategory ? null : (category ?? this.category), ); } } + +enum ProductSort { name, preferred } diff --git a/lib/models/purchase.dart b/lib/models/purchase.dart new file mode 100644 index 0000000..413b832 --- /dev/null +++ b/lib/models/purchase.dart @@ -0,0 +1,36 @@ +/// One time a product was bought, used for price and purchase history. +library; + +class Purchase { + const Purchase({ + required this.shopId, + required this.boughtAt, + required this.supermarket, + required this.price, + this.quantity, + this.unitPrice, + this.receiptImagePath, + }); + + final int shopId; + final DateTime boughtAt; + final String supermarket; + final double price; + final int? quantity; + final double? unitPrice; + final String? receiptImagePath; + + double get unitOrLinePrice => unitPrice ?? price; + + factory Purchase.fromMap(Map map) { + return Purchase( + shopId: map['shop_id']! as int, + boughtAt: DateTime.parse(map['shopped_at']! as String), + supermarket: map['supermarket']! as String, + price: (map['price']! as num).toDouble(), + quantity: map['quantity'] as int?, + unitPrice: (map['unit_price'] as num?)?.toDouble(), + receiptImagePath: map['receipt_image_path'] as String?, + ); + } +} diff --git a/lib/models/shop.dart b/lib/models/shop.dart new file mode 100644 index 0000000..3e44f2f --- /dev/null +++ b/lib/models/shop.dart @@ -0,0 +1,63 @@ +/// A confirmed shopping trip saved from a receipt. +library; + +class Shop { + const Shop({ + required this.id, + required this.supermarket, + required this.shoppedAt, + required this.total, + required this.itemCount, + this.receiptImagePath, + }); + + final int id; + final String supermarket; + final DateTime shoppedAt; + final double total; + final int itemCount; + final String? receiptImagePath; + + factory Shop.fromMap(Map map) { + return Shop( + id: map['id']! as int, + supermarket: map['supermarket']! as String, + shoppedAt: DateTime.parse(map['shopped_at']! as String), + total: (map['total']! as num).toDouble(), + itemCount: (map['item_count'] as num?)?.toInt() ?? 0, + receiptImagePath: map['receipt_image_path'] as String?, + ); + } +} + +class ShopItem { + const ShopItem({ + required this.id, + required this.shopId, + required this.productId, + required this.name, + required this.price, + this.quantity, + this.unitPrice, + }); + + final int id; + final int shopId; + final int productId; + final String name; + final double price; + final int? quantity; + final double? unitPrice; + + factory ShopItem.fromMap(Map map) { + return ShopItem( + id: map['id']! as int, + shopId: map['shop_id']! as int, + productId: map['product_id']! as int, + name: map['name']! as String, + price: (map['price']! as num).toDouble(), + quantity: map['quantity'] as int?, + unitPrice: (map['unit_price'] as num?)?.toDouble(), + ); + } +} diff --git a/lib/models/supermarket.dart b/lib/models/supermarket.dart new file mode 100644 index 0000000..25dc87a --- /dev/null +++ b/lib/models/supermarket.dart @@ -0,0 +1,59 @@ +/// A user-defined supermarket with a display color. +library; + +import 'package:flutter/material.dart'; + +class Supermarket { + const Supermarket({ + this.id, + required this.name, + required this.colorValue, + this.sortOrder = 0, + }); + + final int? id; + final String name; + final int colorValue; + final int sortOrder; + + Color get color => Color(colorValue); + + factory Supermarket.fromMap(Map map) { + return Supermarket( + id: map['id'] as int?, + name: map['name']! as String, + colorValue: map['color']! as int, + sortOrder: map['sort_order'] as int? ?? 0, + ); + } + + Supermarket copyWith({ + int? id, + String? name, + int? colorValue, + int? sortOrder, + }) { + return Supermarket( + id: id ?? this.id, + name: name ?? this.name, + colorValue: colorValue ?? this.colorValue, + sortOrder: sortOrder ?? this.sortOrder, + ); + } +} + +/// Palette offered when the user picks a store color. +const List kStoreColorPalette = [ + 0xFFEEC21B, // Jumbo yellow + 0xFF0050AA, // Lidl blue + 0xFF00A0E2, // AH blue + 0xFF00205B, // Aldi navy + 0xFF6EC31E, // Plus green + 0xFFE30613, // Dirk / Coop red + 0xFF009640, // SPAR green + 0xFFE87722, // orange + 0xFFE31C5F, // Picnic pink + 0xFF7B1FA2, // purple + 0xFF00897B, // teal + 0xFF78909C, // grey +]; diff --git a/lib/product_detail_screen.dart b/lib/product_detail_screen.dart new file mode 100644 index 0000000..9ff6d58 --- /dev/null +++ b/lib/product_detail_screen.dart @@ -0,0 +1,487 @@ +/// Product: photo, name, favourite, price history chart, and notes. +library; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'models/product.dart'; +import 'models/purchase.dart'; +import 'services/app_settings.dart'; +import 'services/product_repository.dart'; +import 'shop_detail_screen.dart'; +import 'utils/categories.dart'; +import 'utils/dates.dart'; +import 'utils/money.dart'; +import 'utils/supermarkets.dart'; +import 'widgets/price_chart.dart'; +import 'widgets/product_image.dart'; +import 'widgets/store_tag.dart'; + +class ProductDetailScreen extends StatefulWidget { + const ProductDetailScreen({ + super.key, + required this.repository, + required this.productId, + }); + + final ProductRepository repository; + final int productId; + + @override + State createState() => _ProductDetailScreenState(); +} + +class _ProductDetailScreenState extends State { + late Future<({Product product, List purchases})> _future; + late final TextEditingController _nameController; + late final TextEditingController _notesController; + final ImagePicker _picker = ImagePicker(); + bool _ready = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _notesController = TextEditingController(); + _future = _load(); + } + + @override + void dispose() { + _saveNotes(); + _nameController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future<({Product product, List purchases})> _load() async { + final product = await widget.repository.getProduct(widget.productId); + if (product == null) { + throw StateError('Product not found'); + } + final purchases = await widget.repository.getPurchases(widget.productId); + if (!_ready) { + _nameController.text = product.name; + _notesController.text = product.notes ?? ''; + _ready = true; + } + return (product: product, purchases: purchases); + } + + void _reload() { + setState(() => _future = _load()); + } + + Future _saveName() async { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + await widget.repository.updateProduct(id: widget.productId, name: name); + if (mounted) _reload(); + } + + Future _saveNotes() async { + await widget.repository.updateProduct( + id: widget.productId, + notes: _notesController.text.trim(), + ); + } + + Future _toggleFavourite(Product product) async { + await widget.repository.setFavourite(product.id, !product.favourite); + if (mounted) _reload(); + } + + Future _setSupermarket(String? value) async { + if (value == null) return; + await widget.repository.updateProduct( + id: widget.productId, + supermarket: value, + ); + if (mounted) _reload(); + } + + Future _pickImage(ImageSource source) async { + final file = await _picker.pickImage(source: source, imageQuality: 85); + if (file == null) return; + await widget.repository.setProductImage(widget.productId, file.path); + if (mounted) _reload(); + } + + Future _chooseImageSource() 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) return; + await _pickImage(source); + } + + Future _pickStore() async { + final settings = SettingsScope.maybeOf(context); + final stores = settings?.supermarkets ?? kDefaultSupermarkets; + final chosen = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final store in stores) + ListTile( + leading: StoreTag(name: store.name, stores: stores), + title: Text(store.name), + onTap: () => Navigator.pop(context, store.name), + ), + ], + ), + ); + }, + ); + if (chosen != null) await _setSupermarket(chosen); + } + + Future _setCategory(String? value, {bool clear = false}) async { + await widget.repository.updateProduct( + id: widget.productId, + category: clear ? null : value, + clearCategory: clear, + ); + if (mounted) _reload(); + } + + Future _pickCategory(Product product) async { + final used = await widget.repository.getUsedCategories(); + if (!mounted) return; + final categories = mergeCategories([ + ...used, + if (product.category != null) product.category!, + ]); + final chosen = await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile( + leading: const Icon(Icons.clear), + title: const Text('No category'), + onTap: () => Navigator.pop(context, ''), + ), + for (final name in categories) + ListTile( + leading: Icon( + name == product.category + ? Icons.check_circle + : Icons.category_outlined, + ), + title: Text(name), + onTap: () => Navigator.pop(context, name), + ), + ListTile( + leading: const Icon(Icons.add), + title: const Text('Custom category'), + onTap: () => Navigator.pop(context, '__custom__'), + ), + ], + ), + ); + }, + ); + if (!mounted || chosen == null) return; + if (chosen.isEmpty) { + await _setCategory(null, clear: true); + return; + } + if (chosen == '__custom__') { + final custom = await _askCustomCategory(product.category); + if (custom == null || !mounted) return; + if (custom.isEmpty) { + await _setCategory(null, clear: true); + } else { + await _setCategory(custom); + } + return; + } + await _setCategory(chosen); + } + + Future _askCustomCategory(String? current) async { + return showDialog( + context: context, + builder: (context) => _CategoryNameDialog(initial: current), + ); + } + + Future _openShop(int shopId) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ShopDetailScreen( + repository: widget.repository, + shopId: shopId, + ), + ), + ); + if (mounted) _reload(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + 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()); + } + + 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, + ), + ), + ), + ), + ], + ), + ), + 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, + ), + ), + 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(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(), + ), + ], + ); + }, + ), + ); + } +} + +class _CategoryNameDialog extends StatefulWidget { + const _CategoryNameDialog({this.initial}); + + final String? initial; + + @override + State<_CategoryNameDialog> createState() => _CategoryNameDialogState(); +} + +class _CategoryNameDialogState extends State<_CategoryNameDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initial ?? ''); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _save() => Navigator.pop(context, _controller.text.trim()); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Category'), + content: TextField( + controller: _controller, + textCapitalization: TextCapitalization.sentences, + autofocus: true, + decoration: const InputDecoration(labelText: 'Category'), + onSubmitted: (_) => _save(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _save, + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/products_screen.dart b/lib/products_screen.dart index b4f51c7..7cb2075 100644 --- a/lib/products_screen.dart +++ b/lib/products_screen.dart @@ -1,100 +1,254 @@ -/// Confirmed products stored on device. +/// Catalog of products from confirmed receipts. library; import 'package:flutter/material.dart'; import 'models/product.dart'; +import 'product_detail_screen.dart'; +import 'services/app_settings.dart'; import 'services/product_repository.dart'; +import 'utils/fuzzy.dart'; import 'utils/money.dart'; +import 'widgets/product_image.dart'; +import 'widgets/settings_button.dart'; +import 'widgets/store_tag.dart'; class ProductsScreen extends StatefulWidget { - const ProductsScreen({ - super.key, - required this.repository, - this.savedCount, - }); + const ProductsScreen({super.key, required this.repository}); final ProductRepository repository; - final int? savedCount; @override State createState() => _ProductsScreenState(); } class _ProductsScreenState extends State { + ProductSort _sort = ProductSort.preferred; + bool _favouritesOnly = false; + bool _searching = false; + final TextEditingController _search = TextEditingController(); late Future> _future; @override void initState() { super.initState(); - _future = widget.repository.getAll(); - final saved = widget.savedCount; - if (saved != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - saved == 1 - ? 'Saved 1 product.' - : 'Saved $saved products.', - ), - ), - ); - }); + _future = _load(); + _search.addListener(() => setState(() {})); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + Future> _load() { + return widget.repository.getAll( + sort: _sort, + favouritesOnly: _favouritesOnly, + ); + } + + void _reload() { + setState(() => _future = _load()); + } + + List _filter(List products) { + final query = _search.text.trim(); + if (query.isEmpty) return products; + final ranked = <({Product product, int score})>[]; + for (final product in products) { + final haystack = [ + product.name, + if (product.supermarket != null) product.supermarket!, + if (product.category != null) product.category!, + if (product.notes != null) product.notes!, + ].join(' '); + final score = fuzzyScore(query, haystack); + if (score != null) { + ranked.add((product: product, score: score)); + } } + ranked.sort((a, b) => b.score.compareTo(a.score)); + return [for (final row in ranked) row.product]; + } + + Future _openProduct(Product product) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProductDetailScreen( + repository: widget.repository, + productId: product.id, + ), + ), + ); + if (mounted) _reload(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); + final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; return Scaffold( - appBar: AppBar(title: const Text('Products')), - body: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text( - 'Could not load products.', - style: theme.textTheme.bodyLarge, - ), - ); - } - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - - final products = snapshot.data!; - if (products.isEmpty) { - return Center( - child: Text( - 'Confirmed receipt items will show up here.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge, - ), - ); - } - - return ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: products.length, - separatorBuilder: (_, _) => const Divider(indent: 16, endIndent: 16), - itemBuilder: (context, index) { - final product = products[index]; - final seen = product.timesSeen; - return ListTile( - title: Text(product.name), - subtitle: Text(seen == 1 ? 'Seen once' : 'Seen $seen times'), - trailing: Text( - formatEuro(product.lastPrice), - style: theme.textTheme.titleMedium, + appBar: AppBar( + title: _searching + ? TextField( + controller: _search, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search products', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, ), - ); + ) + : const Text('Products'), + actions: [ + IconButton( + tooltip: _searching ? 'Close search' : 'Search', + onPressed: () { + setState(() { + _searching = !_searching; + if (!_searching) _search.clear(); + }); }, - ); - }, + icon: Icon(_searching ? Icons.close : Icons.search), + ), + const SettingsButton(), + ], + ), + body: Column( + 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; + _future = _load(); + }); + }, + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: 'Favourites', + isSelected: _favouritesOnly, + onPressed: () { + setState(() { + _favouritesOnly = !_favouritesOnly; + _future = _load(); + }); + }, + icon: Icon( + _favouritesOnly ? Icons.star : Icons.star_outline, + ), + ), + ], + ), + ), + Expanded( + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + 'Could not load products.', + style: theme.textTheme.bodyLarge, + ), + ); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final products = _filter(snapshot.data!); + if (snapshot.data!.isEmpty) { + return Center( + child: Text( + _favouritesOnly + ? 'Star products to keep them as favourites.' + : 'Confirmed receipt items will show up here.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ); + } + if (products.isEmpty) { + return Center( + child: Text( + 'No products match this search.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 16), + itemCount: products.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final product = products[index]; + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 0, + ), + leading: ProductImage( + name: product.name, + path: product.imagePath, + size: 36, + ), + title: Text( + product.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: product.supermarket == null + ? null + : Padding( + padding: const EdgeInsets.only(top: 2), + child: Align( + alignment: Alignment.centerLeft, + child: StoreTag( + name: product.supermarket, + stores: stores, + compact: true, + ), + ), + ), + trailing: Text( + formatEuro(product.lastPrice), + style: theme.textTheme.titleMedium, + ), + onTap: () => _openProduct(product), + ); + }, + ); + }, + ), + ), + ], ), ); } diff --git a/lib/review_screen.dart b/lib/review_screen.dart index 6c270ff..6227ba2 100644 --- a/lib/review_screen.dart +++ b/lib/review_screen.dart @@ -1,12 +1,15 @@ -/// Editable list of parsed receipt lines before they are saved as products. +/// Editable list of parsed receipt lines before they are saved as a shop trip. library; import 'package:flutter/material.dart'; import 'models/line_item.dart'; -import 'products_screen.dart'; +import 'services/app_settings.dart'; import 'services/product_repository.dart'; +import 'utils/dates.dart'; import 'utils/money.dart'; +import 'utils/supermarkets.dart'; +import 'widgets/store_tag.dart'; class ReviewScreen extends StatefulWidget { const ReviewScreen({ @@ -14,11 +17,15 @@ class ReviewScreen extends StatefulWidget { required this.items, required this.rawText, required this.repository, + this.imagePath, + this.supermarketNames = kSupermarkets, }); final List items; final String rawText; final ProductRepository repository; + final String? imagePath; + final List supermarketNames; @override State createState() => _ReviewScreenState(); @@ -26,12 +33,33 @@ class ReviewScreen extends StatefulWidget { class _ReviewScreenState extends State { late List _items; + late DateTime _shoppedAt; + String? _supermarket; + String? _customStore; bool _saving = false; @override void initState() { super.initState(); _items = List.from(widget.items); + _shoppedAt = DateTime.now(); + final detected = detectSupermarket( + widget.rawText, + names: widget.supermarketNames, + ); + if (detected != null) { + _supermarket = detected; + } + } + + String? get _resolvedSupermarket { + if (_supermarket == null) return null; + if (_supermarket == kOtherSupermarket) { + final custom = _customStore?.trim(); + if (custom == null || custom.isEmpty) return null; + return custom; + } + return _supermarket; } Future _edit({LineItem? existing}) async { @@ -55,25 +83,47 @@ class _ReviewScreenState extends State { }); } + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _shoppedAt, + firstDate: DateTime(2018), + lastDate: DateTime.now().add(const Duration(days: 1)), + ); + if (picked == null || !mounted) return; + setState(() => _shoppedAt = picked); + } + Future _confirm() async { + final supermarket = _resolvedSupermarket; + if (supermarket == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Select a supermarket.')), + ); + return; + } + if (_items.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Add at least one product.')), + ); + return; + } + setState(() => _saving = true); try { - await widget.repository.upsertItems(_items); - if (!mounted) return; - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (_) => ProductsScreen( - repository: widget.repository, - savedCount: _items.length, - ), - ), - (route) => route.isFirst, + await widget.repository.saveShop( + supermarket: supermarket, + shoppedAt: _shoppedAt, + items: _items, + receiptImagePath: widget.imagePath, ); + if (!mounted) return; + Navigator.of(context).pop(true); } catch (_) { if (!mounted) return; setState(() => _saving = false); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not save products.')), + const SnackBar(content: Text('Could not save this shopping trip.')), ); } } @@ -81,11 +131,69 @@ class _ReviewScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final total = _items.fold(0, (sum, item) => sum + item.price); + final settings = SettingsScope.maybeOf(context); + final stores = settings?.supermarkets ?? kDefaultSupermarkets; + final names = [ + ...{...widget.supermarketNames, ...stores.map((s) => s.name)}, + ]; + if (_supermarket != null && !names.contains(_supermarket)) { + names.add(_supermarket!); + } return Scaffold( appBar: AppBar(title: const Text('Review items')), body: Column( children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + key: ValueKey(_supermarket), + initialValue: _supermarket, + hint: const Text('Select supermarket'), + decoration: const InputDecoration( + labelText: 'Supermarket', + ), + items: [ + for (final store in names) + DropdownMenuItem( + value: store, + child: StoreTag( + name: store, + stores: stores, + compact: true, + ), + ), + ], + onChanged: _saving + ? null + : (value) => setState(() => _supermarket = value), + ), + if (_supermarket == kOtherSupermarket) ...[ + const SizedBox(height: 12), + TextField( + enabled: !_saving, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Store name', + ), + onChanged: (value) => _customStore = value, + ), + ], + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.calendar_today_outlined), + title: Text(formatDate(_shoppedAt)), + subtitle: const Text('Shopping date'), + onTap: _saving ? null : _pickDate, + ), + ], + ), + ), Expanded( child: _items.isEmpty ? Center( @@ -166,7 +274,9 @@ class _ReviewScreenState extends State { FilledButton( onPressed: _saving ? null : _confirm, child: Text( - _saving ? 'Saving…' : 'Confirm', + _saving + ? 'Saving…' + : 'Save trip · ${formatEuro(total)}', ), ), ], diff --git a/lib/scan_screen.dart b/lib/scan_screen.dart index f00a55a..1eaf0aa 100644 --- a/lib/scan_screen.dart +++ b/lib/scan_screen.dart @@ -1,4 +1,4 @@ -/// Single screen: capture or pick a receipt photo, then show raw OCR text. +/// Capture or pick a receipt photo, then review parsed line items. library; import 'dart:io'; @@ -8,16 +8,23 @@ import 'package:flutter/services.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'package:image_picker/image_picker.dart'; -import 'products_screen.dart'; import 'review_screen.dart'; +import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'services/receipt_parser.dart'; import 'utils/constants.dart'; +import 'utils/supermarkets.dart'; +import 'widgets/settings_button.dart'; class ScanScreen extends StatefulWidget { - const ScanScreen({super.key, required this.repository}); + const ScanScreen({ + super.key, + required this.repository, + this.onTripSaved, + }); final ProductRepository repository; + final VoidCallback? onTripSaved; @override State createState() => _ScanScreenState(); @@ -27,6 +34,7 @@ class _ScanScreenState extends State { final ImagePicker _picker = ImagePicker(); String _extractedText = ''; + String? _imagePath; bool _busy = false; String? _error; @@ -81,6 +89,7 @@ class _ScanScreenState extends State { _busy = true; _error = null; _extractedText = ''; + _imagePath = imagePath; }); final recognizer = TextRecognizer(script: TextRecognitionScript.latin); @@ -114,23 +123,23 @@ class _ScanScreenState extends State { Future _openReview(String rawText) async { final items = ReceiptParser.parse(rawText); if (!mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( + final names = + SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ?? + kSupermarkets; + final saved = await Navigator.of(context).push( + MaterialPageRoute( builder: (_) => ReviewScreen( items: items, rawText: rawText, repository: widget.repository, + imagePath: _imagePath, + supermarketNames: names.toList(), ), ), ); - } - - void _openProducts() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ProductsScreen(repository: widget.repository), - ), - ); + if (saved == true && mounted) { + widget.onTripSaved?.call(); + } } @override @@ -140,13 +149,7 @@ class _ScanScreenState extends State { return Scaffold( appBar: AppBar( title: const Text(kAppName), - actions: [ - IconButton( - tooltip: 'Products', - onPressed: _openProducts, - icon: const Icon(Icons.inventory_2_outlined), - ), - ], + actions: const [SettingsButton()], ), body: SafeArea( child: Padding( @@ -220,7 +223,7 @@ class _ResultPane extends StatelessWidget { if (text.isEmpty) { return Center( child: Text( - 'Take a photo of a shopping receipt, or pick one from the gallery.\n\nExtracted text will show up here.', + 'Take a photo of a shopping receipt, or pick one from the gallery.\n\nConfirmed trips show up in Shop log.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), diff --git a/lib/services/app_settings.dart b/lib/services/app_settings.dart new file mode 100644 index 0000000..8a8f186 --- /dev/null +++ b/lib/services/app_settings.dart @@ -0,0 +1,89 @@ +/// App-wide settings: dark mode and the user supermarket list. +library; + +import 'package:flutter/material.dart'; + +import '../models/supermarket.dart'; +import '../utils/supermarkets.dart'; +import 'product_repository.dart'; + +class AppSettings extends ChangeNotifier { + AppSettings(this.repository); + + final ProductRepository repository; + + bool darkMode = false; + List supermarkets = List.from(kDefaultSupermarkets); + int catalogGeneration = 0; + + Future load() async { + try { + darkMode = await repository.getDarkMode(); + supermarkets = await repository.getSupermarkets(); + notifyListeners(); + } catch (_) { + // Widget tests and first-run without a plugin keep the defaults. + } + } + + Future setDarkMode(bool enabled) async { + darkMode = enabled; + notifyListeners(); + try { + await repository.setDarkMode(enabled); + } catch (_) {} + } + + Future addSupermarket(String name, int colorValue) async { + final trimmed = name.trim(); + if (trimmed.isEmpty) return; + final store = await repository.addSupermarket( + name: trimmed, + colorValue: colorValue, + ); + supermarkets = [...supermarkets, store]; + notifyListeners(); + } + + Future updateSupermarket(Supermarket store) async { + await repository.updateSupermarket(store); + supermarkets = [ + for (final item in supermarkets) + if (item.id == store.id) store else item, + ]; + notifyListeners(); + } + + Future deleteSupermarket(int id) async { + await repository.deleteSupermarket(id); + supermarkets = [ + for (final item in supermarkets) + if (item.id != id) item, + ]; + notifyListeners(); + } + + Future reload() async { + await load(); + catalogGeneration++; + notifyListeners(); + } +} + +class SettingsScope extends InheritedNotifier { + const SettingsScope({ + super.key, + required AppSettings settings, + required super.child, + }) : super(notifier: settings); + + static AppSettings of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'SettingsScope not found'); + return scope!.notifier!; + } + + static AppSettings? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.notifier; + } +} diff --git a/lib/services/backup_format.dart b/lib/services/backup_format.dart new file mode 100644 index 0000000..0bab531 --- /dev/null +++ b/lib/services/backup_format.dart @@ -0,0 +1,99 @@ +/// JSON backup document: products, trips, settings, and embedded photos. +library; + +import 'dart:convert'; + +const int kBackupVersion = 1; + +class BackupDocument { + const BackupDocument({ + required this.version, + required this.exportedAt, + required this.darkMode, + required this.supermarkets, + required this.products, + required this.shops, + required this.shopItems, + required this.images, + }); + + final int version; + final String exportedAt; + final bool darkMode; + final List> supermarkets; + final List> products; + final List> shops; + final List> shopItems; + final Map images; + + Map toJson() => { + 'format': 'receipity-backup', + 'version': version, + 'exportedAt': exportedAt, + 'darkMode': darkMode, + 'supermarkets': supermarkets, + 'products': products, + 'shops': shops, + 'shopItems': shopItems, + 'images': images, + }; + + String encode() => const JsonEncoder.withIndent(' ').convert(toJson()); + + static BackupDocument parse(String json) { + final decoded = jsonDecode(json); + if (decoded is! Map) { + throw const FormatException('Backup is not a JSON object.'); + } + if (decoded['format'] != 'receipity-backup') { + throw const FormatException('This file is not a Receipity backup.'); + } + final version = decoded['version']; + if (version is! int || version < 1 || version > kBackupVersion) { + throw const FormatException('Unsupported backup version.'); + } + return BackupDocument( + version: version, + exportedAt: decoded['exportedAt'] as String? ?? '', + darkMode: decoded['darkMode'] == true, + supermarkets: _maps(decoded['supermarkets']), + products: _maps(decoded['products']), + shops: _maps(decoded['shops']), + shopItems: _maps(decoded['shopItems']), + images: _strings(decoded['images']), + ); + } + + static List> _maps(Object? value) { + if (value == null) return []; + if (value is! List) { + throw const FormatException('Backup tables must be lists.'); + } + return [ + for (final item in value) + if (item is Map) + Map.from( + item.map((key, val) => MapEntry(key.toString(), val)), + ), + ]; + } + + static Map _strings(Object? value) { + if (value == null) return {}; + if (value is! Map) return {}; + return { + for (final entry in value.entries) + if (entry.value is String) entry.key.toString(): entry.value as String, + }; + } +} + +String? backupImageKey(String? absolutePath) { + if (absolutePath == null || absolutePath.isEmpty) return null; + final normalized = absolutePath.replaceAll('\\', '/'); + final products = normalized.split('/products/'); + if (products.length == 2) return 'products/${products.last}'; + final receipts = normalized.split('/receipts/'); + if (receipts.length == 2) return 'receipts/${receipts.last}'; + return null; +} diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart new file mode 100644 index 0000000..683827f --- /dev/null +++ b/lib/services/backup_service.dart @@ -0,0 +1,161 @@ +/// Build, share, and restore a Receipity JSON backup. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'backup_format.dart'; +import 'product_repository.dart'; + +class BackupService { + BackupService(this.repository); + + final ProductRepository repository; + + Future buildBackup() async { + final products = await repository.dumpTable('products'); + final shops = await repository.dumpTable('shops'); + final images = {}; + + Future addImage(String? path) async { + final key = backupImageKey(path); + if (key == null || images.containsKey(key)) return; + final file = File(path!); + if (!file.existsSync()) return; + images[key] = base64Encode(await file.readAsBytes()); + } + + final rewrittenProducts = >[]; + for (final row in products) { + final copy = Map.from(row); + final path = copy['image_path'] as String?; + await addImage(path); + copy['image_path'] = backupImageKey(path); + rewrittenProducts.add(copy); + } + + final rewrittenShops = >[]; + for (final row in shops) { + final copy = Map.from(row); + final path = copy['receipt_image_path'] as String?; + await addImage(path); + copy['receipt_image_path'] = backupImageKey(path); + rewrittenShops.add(copy); + } + + return BackupDocument( + version: kBackupVersion, + exportedAt: DateTime.now().toIso8601String(), + darkMode: await repository.getDarkMode(), + supermarkets: await repository.dumpTable('supermarkets'), + products: rewrittenProducts, + shops: rewrittenShops, + shopItems: await repository.dumpTable('shop_items'), + images: images, + ); + } + + Future exportBackup() async { + final document = await buildBackup(); + final bytes = Uint8List.fromList(utf8.encode(document.encode())); + final stamp = DateTime.now().toIso8601String().split('T').first; + final fileName = 'receipity-backup-$stamp.json'; + + try { + final saved = await FilePicker.saveFile( + dialogTitle: 'Save Receipity backup', + fileName: fileName, + bytes: bytes, + mimeType: 'application/json', + type: FileType.custom, + allowedExtensions: const ['json'], + ); + return saved != null; + } catch (_) { + // Fall through to the share sheet if save-as is unavailable. + } + + final dir = await getTemporaryDirectory(); + final file = File(p.join(dir.path, fileName)); + await file.writeAsBytes(bytes, flush: true); + final result = await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'application/json')], + subject: 'Receipity backup', + text: 'Receipity backup $stamp', + ), + ); + return result.status != ShareResultStatus.dismissed; + } + + Future importBackup() async { + final picked = await FilePicker.pickFile( + dialogTitle: 'Import Receipity backup', + type: FileType.custom, + allowedExtensions: const ['json'], + ); + if (picked == null) return false; + final bytes = await picked.readAsBytes(); + await restoreBackup(utf8.decode(bytes)); + return true; + } + + Future restoreBackup(String json) async { + final document = BackupDocument.parse(json); + await repository.clearImageFolders(); + + final products = >[]; + final now = DateTime.now().toIso8601String(); + for (final row in document.products) { + final copy = Map.from(row); + copy['image_path'] = await _restoreImage(copy['image_path'], document.images); + copy['created_at'] ??= now; + copy['updated_at'] ??= copy['created_at']; + if (copy['normalized_name'] == null && copy['name'] is String) { + copy['normalized_name'] = ProductRepository.normalizeName( + copy['name']! as String, + ); + } + products.add(copy); + } + + final shops = >[]; + for (final row in document.shops) { + final copy = Map.from(row); + copy['receipt_image_path'] = await _restoreImage( + copy['receipt_image_path'], + document.images, + ); + copy['created_at'] ??= now; + shops.add(copy); + } + + await repository.replaceAllData( + supermarkets: document.supermarkets, + products: products, + shops: shops, + shopItems: document.shopItems, + darkMode: document.darkMode, + ); + } + + Future _restoreImage( + Object? key, + Map images, + ) async { + if (key is! String || key.isEmpty) return null; + final encoded = images[key]; + if (encoded == null) return null; + try { + return await repository.writeImageBytes(key, base64Decode(encoded)); + } catch (_) { + return null; + } + } +} diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index 7ebe357..1bca3d2 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -1,14 +1,24 @@ -/// Local SQLite store of confirmed products. +/// Local SQLite store of products and shopping trips. library; +import 'dart:io'; + import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import '../models/line_item.dart'; import '../models/product.dart'; +import '../models/purchase.dart'; +import '../models/shop.dart'; +import '../models/supermarket.dart'; +import '../utils/money.dart'; +import '../utils/supermarkets.dart'; class ProductRepository { + ProductRepository({this.databasePath}); + + final String? databasePath; Database? _db; Future get _database async { @@ -17,77 +27,664 @@ class ProductRepository { } Future _open() async { - final dir = await getApplicationDocumentsDirectory(); + final path = databasePath ?? + p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); return openDatabase( - p.join(dir.path, 'receipity.db'), - version: 1, + path, + version: 4, onCreate: (db, version) async { - await db.execute(''' - CREATE TABLE products ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - normalized_name TEXT NOT NULL UNIQUE, - last_price REAL NOT NULL, - times_seen INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - '''); + await _createProducts(db); + await _createShopTables(db); + await _createSettingsTables(db); + await _seedSupermarkets(db); + }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + await db.execute( + 'ALTER TABLE products ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0', + ); + await db.execute('ALTER TABLE products ADD COLUMN image_path TEXT'); + await db.execute('ALTER TABLE products ADD COLUMN supermarket TEXT'); + await _createShopTables(db); + } + if (oldVersion < 3) { + await db.execute('ALTER TABLE products ADD COLUMN notes TEXT'); + await _createSettingsTables(db); + await _seedSupermarkets(db); + } + if (oldVersion < 4) { + await db.execute('ALTER TABLE products ADD COLUMN category TEXT'); + } }, ); } + Future _createProducts(Database db) async { + await db.execute(''' + CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL UNIQUE, + last_price REAL NOT NULL, + times_seen INTEGER NOT NULL DEFAULT 1, + favourite INTEGER NOT NULL DEFAULT 0, + image_path TEXT, + supermarket TEXT, + notes TEXT, + category TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + } + + Future _createSettingsTables(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS supermarkets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color INTEGER NOT NULL, + sort_order INTEGER NOT NULL + ) + '''); + } + + Future _seedSupermarkets(DatabaseExecutor db) async { + final existing = await db.query('supermarkets', limit: 1); + if (existing.isNotEmpty) return; + for (final store in kDefaultSupermarkets) { + await db.insert('supermarkets', { + 'name': store.name, + 'color': store.colorValue, + 'sort_order': store.sortOrder, + }); + } + } + + Future _createShopTables(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE shops ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + supermarket TEXT NOT NULL, + shopped_at TEXT NOT NULL, + receipt_image_path TEXT, + total REAL NOT NULL, + created_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE shop_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shop_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + name TEXT NOT NULL, + price REAL NOT NULL, + quantity INTEGER, + unit_price REAL, + FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) + ) + '''); + } + static String normalizeName(String name) => name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); - Future upsertItems(List items) async { + Future _persistImage(String sourcePath, String relativePath) async { + final dir = await getApplicationDocumentsDirectory(); + final dest = File(p.join(dir.path, relativePath)); + await dest.parent.create(recursive: true); + if (p.normalize(sourcePath) == p.normalize(dest.path)) { + return dest.path; + } + await File(sourcePath).copy(dest.path); + return dest.path; + } + + /// Saves a shopping trip and upserts each line into the product catalog. + Future saveShop({ + required String supermarket, + required DateTime shoppedAt, + required List items, + String? receiptImagePath, + }) async { final db = await _database; final now = DateTime.now().toIso8601String(); + final total = roundMoney( + items.fold(0, (sum, item) => sum + item.price), + ); + + final shopId = await db.transaction((txn) async { + final id = await txn.insert('shops', { + 'supermarket': supermarket, + 'shopped_at': shoppedAt.toIso8601String(), + 'total': total, + 'created_at': now, + }); - await db.transaction((txn) async { for (final item in items) { - final key = normalizeName(item.name); - if (key.isEmpty) continue; - - final existing = await txn.query( - 'products', - where: 'normalized_name = ?', - whereArgs: [key], - limit: 1, + final productId = await _upsertProduct( + txn, + item: item, + supermarket: supermarket, + now: now, ); - - if (existing.isEmpty) { - await txn.insert('products', { - 'name': item.name.trim(), - 'normalized_name': key, - 'last_price': item.price, - 'times_seen': 1, - 'created_at': now, - 'updated_at': now, - }); - } else { - await txn.update( - 'products', - { - 'name': item.name.trim(), - 'last_price': item.price, - 'times_seen': (existing.first['times_seen']! as int) + 1, - 'updated_at': now, - }, - where: 'id = ?', - whereArgs: [existing.first['id']], - ); - } + await txn.insert('shop_items', { + 'shop_id': id, + 'product_id': productId, + 'name': item.name.trim(), + 'price': item.price, + 'quantity': item.quantity, + 'unit_price': item.unitPrice, + }); } + return id; + }); + + if (receiptImagePath != null && receiptImagePath.isNotEmpty) { + try { + final stored = await _persistImage( + receiptImagePath, + 'receipts/shop_$shopId.jpg', + ); + await db.update( + 'shops', + {'receipt_image_path': stored}, + where: 'id = ?', + whereArgs: [shopId], + ); + } catch (_) { + // Trip is still saved if the photo cannot be copied. + } + } + + return shopId; + } + + Future _upsertProduct( + Transaction txn, { + required LineItem item, + required String supermarket, + required String now, + }) async { + final key = normalizeName(item.name); + final existing = await txn.query( + 'products', + where: 'normalized_name = ?', + whereArgs: [key], + limit: 1, + ); + + if (existing.isEmpty) { + return txn.insert('products', { + 'name': item.name.trim(), + 'normalized_name': key, + 'last_price': item.unitPrice ?? item.price, + 'times_seen': 1, + 'favourite': 0, + 'supermarket': supermarket, + 'created_at': now, + 'updated_at': now, + }); + } + + final row = existing.first; + final values = { + 'last_price': item.unitPrice ?? item.price, + 'times_seen': (row['times_seen']! as int) + 1, + 'updated_at': now, + }; + if ((row['supermarket'] as String?) == null || + (row['supermarket'] as String).isEmpty) { + values['supermarket'] = supermarket; + } + await txn.update( + 'products', + values, + where: 'id = ?', + whereArgs: [row['id']], + ); + return row['id']! as int; + } + + /// Kept for tests and older callers; saves items without a shop trip. + Future upsertItems(List items) async { + await saveShop( + supermarket: 'Other', + shoppedAt: DateTime.now(), + items: items, + ); + } + + 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', + }; + final rows = await db.rawQuery(''' + SELECT + products.*, + ( + SELECT COALESCE(shop_items.unit_price, shop_items.price) + FROM shop_items + JOIN shops ON shops.id = shop_items.shop_id + WHERE shop_items.product_id = products.id + ORDER BY shops.shopped_at DESC, shop_items.id DESC + LIMIT 1 + ) AS display_price + FROM products + ${favouritesOnly ? 'WHERE favourite = 1' : ''} + ORDER BY $orderBy + '''); + return rows.map(Product.fromMap).toList(); + } + + Future getProduct(int id) async { + final db = await _database; + final rows = await db.rawQuery( + ''' + SELECT + products.*, + ( + SELECT COALESCE(shop_items.unit_price, shop_items.price) + FROM shop_items + JOIN shops ON shops.id = shop_items.shop_id + WHERE shop_items.product_id = products.id + ORDER BY shops.shopped_at DESC, shop_items.id DESC + LIMIT 1 + ) AS display_price + FROM products + WHERE products.id = ? + LIMIT 1 + ''', + [id], + ); + if (rows.isEmpty) return null; + return Product.fromMap(rows.first); + } + + Future> getUsedCategories() async { + final db = await _database; + final rows = await db.rawQuery( + ''' + SELECT DISTINCT category FROM products + WHERE category IS NOT NULL AND TRIM(category) != '' + ORDER BY category COLLATE NOCASE ASC + ''', + ); + return [ + for (final row in rows) + if (row['category'] is String) row['category']! as String, + ]; + } + + Future updateProduct({ + required int id, + String? name, + bool? favourite, + String? supermarket, + String? imagePath, + String? notes, + String? category, + bool clearImage = false, + bool clearSupermarket = false, + bool clearNotes = false, + bool clearCategory = false, + }) async { + final db = await _database; + final values = { + 'updated_at': DateTime.now().toIso8601String(), + }; + if (name != null) { + values['name'] = name.trim(); + values['normalized_name'] = normalizeName(name); + } + if (favourite != null) values['favourite'] = favourite ? 1 : 0; + if (clearSupermarket) { + values['supermarket'] = null; + } else if (supermarket != null) { + values['supermarket'] = supermarket; + } + if (clearImage) { + values['image_path'] = null; + } else if (imagePath != null) { + values['image_path'] = imagePath; + } + if (clearNotes) { + values['notes'] = null; + } else if (notes != null) { + values['notes'] = notes; + } + if (clearCategory) { + values['category'] = null; + } else if (category != null) { + values['category'] = category.trim(); + } + await db.update('products', values, where: 'id = ?', whereArgs: [id]); + } + + Future setFavourite(int id, bool favourite) => + updateProduct(id: id, favourite: favourite); + + Future setProductImage(int id, String sourcePath) async { + final stored = await _persistImage(sourcePath, 'products/product_$id.jpg'); + await updateProduct(id: id, imagePath: stored); + return stored; + } + + Future> getPurchases(int productId) async { + final db = await _database; + final rows = await db.rawQuery( + ''' + SELECT + shop_items.shop_id, + shop_items.price, + shop_items.quantity, + shop_items.unit_price, + shops.shopped_at, + shops.supermarket, + shops.receipt_image_path + 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 + ''', + [productId], + ); + return rows.map(Purchase.fromMap).toList(); + } + + Future> getShops() async { + final db = await _database; + final rows = await db.rawQuery(''' + SELECT + shops.id, + shops.supermarket, + shops.shopped_at, + shops.receipt_image_path, + shops.total, + COUNT(shop_items.id) AS item_count + FROM shops + LEFT JOIN shop_items ON shop_items.shop_id = shops.id + GROUP BY shops.id + ORDER BY shops.shopped_at DESC, shops.id DESC + '''); + return rows.map(Shop.fromMap).toList(); + } + + Future getShop(int id) async { + final db = await _database; + final rows = await db.rawQuery( + ''' + SELECT + shops.id, + shops.supermarket, + shops.shopped_at, + shops.receipt_image_path, + shops.total, + COUNT(shop_items.id) AS item_count + FROM shops + LEFT JOIN shop_items ON shop_items.shop_id = shops.id + WHERE shops.id = ? + GROUP BY shops.id + ''', + [id], + ); + if (rows.isEmpty) return null; + return Shop.fromMap(rows.first); + } + + Future> getShopItems(int shopId) async { + final db = await _database; + final rows = await db.query( + 'shop_items', + where: 'shop_id = ?', + whereArgs: [shopId], + orderBy: 'id ASC', + ); + return rows.map(ShopItem.fromMap).toList(); + } + + Future getDarkMode() async { + final db = await _database; + final rows = await db.query( + 'app_settings', + where: 'key = ?', + whereArgs: ['dark_mode'], + limit: 1, + ); + if (rows.isEmpty) return false; + return rows.first['value'] == '1'; + } + + Future setDarkMode(bool enabled) async { + final db = await _database; + await db.insert( + 'app_settings', + {'key': 'dark_mode', 'value': enabled ? '1' : '0'}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future> getSupermarkets() async { + final db = await _database; + final rows = await db.query('supermarkets', orderBy: 'sort_order ASC, id ASC'); + if (rows.isEmpty) { + await _seedSupermarkets(db); + return getSupermarkets(); + } + return rows.map(Supermarket.fromMap).toList(); + } + + Future addSupermarket({ + required String name, + required int colorValue, + }) async { + final db = await _database; + final maxOrder = Sqflite.firstIntValue( + await db.rawQuery('SELECT MAX(sort_order) FROM supermarkets'), + ); + final id = await db.insert('supermarkets', { + 'name': name.trim(), + 'color': colorValue, + 'sort_order': (maxOrder ?? -1) + 1, + }); + return Supermarket( + id: id, + name: name.trim(), + colorValue: colorValue, + sortOrder: (maxOrder ?? -1) + 1, + ); + } + + Future updateSupermarket(Supermarket store) async { + final id = store.id; + if (id == null) return; + final db = await _database; + final existing = await db.query( + 'supermarkets', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (existing.isEmpty) return; + final oldName = existing.first['name']! as String; + await db.update( + 'supermarkets', + { + 'name': store.name.trim(), + 'color': store.colorValue, + 'sort_order': store.sortOrder, + }, + where: 'id = ?', + whereArgs: [id], + ); + if (oldName != store.name.trim()) { + await db.update( + 'products', + {'supermarket': store.name.trim()}, + where: 'supermarket = ?', + whereArgs: [oldName], + ); + await db.update( + 'shops', + {'supermarket': store.name.trim()}, + where: 'supermarket = ?', + whereArgs: [oldName], + ); + } + } + + Future deleteSupermarket(int id) async { + final db = await _database; + await db.delete('supermarkets', where: 'id = ?', whereArgs: [id]); + } + + Future>> dumpTable(String table) async { + final db = await _database; + return db.query(table, orderBy: 'id ASC'); + } + + Future writeImageBytes(String relativePath, List bytes) async { + final dir = await getApplicationDocumentsDirectory(); + final dest = File(p.join(dir.path, relativePath)); + await dest.parent.create(recursive: true); + await dest.writeAsBytes(bytes, flush: true); + return dest.path; + } + + Future clearImageFolders() async { + final dir = await getApplicationDocumentsDirectory(); + for (final name in ['receipts', 'products']) { + final folder = Directory(p.join(dir.path, name)); + if (await folder.exists()) { + await folder.delete(recursive: true); + } + } + } + + Future replaceAllData({ + required List> supermarkets, + required List> products, + required List> shops, + required List> shopItems, + required bool darkMode, + }) async { + final db = await _database; + await db.transaction((txn) async { + await txn.delete('shop_items'); + await txn.delete('shops'); + await txn.delete('products'); + await txn.delete('supermarkets'); + await txn.delete('app_settings'); + + for (final row in supermarkets) { + await txn.insert('supermarkets', _sqlRow(row, _supermarketColumns)); + } + for (final row in products) { + await txn.insert('products', _sqlRow(row, _productColumns)); + } + for (final row in shops) { + await txn.insert('shops', _sqlRow(row, _shopColumns)); + } + for (final row in shopItems) { + await txn.insert('shop_items', _sqlRow(row, _shopItemColumns)); + } + await txn.insert('app_settings', { + 'key': 'dark_mode', + 'value': darkMode ? '1' : '0', + }); + + await _resetSequence(txn, 'supermarkets'); + await _resetSequence(txn, 'products'); + await _resetSequence(txn, 'shops'); + await _resetSequence(txn, 'shop_items'); }); } - Future> getAll() async { - final db = await _database; - final rows = await db.query( - 'products', - orderBy: 'name COLLATE NOCASE ASC', - ); - return rows.map(Product.fromMap).toList(); + static const _supermarketColumns = { + 'id', + 'name', + 'color', + 'sort_order', + }; + static const _productColumns = { + 'id', + 'name', + 'normalized_name', + 'last_price', + 'times_seen', + 'favourite', + 'image_path', + 'supermarket', + 'notes', + 'category', + 'created_at', + 'updated_at', + }; + static const _shopColumns = { + 'id', + 'supermarket', + 'shopped_at', + 'receipt_image_path', + 'total', + 'created_at', + }; + static const _shopItemColumns = { + 'id', + 'shop_id', + 'product_id', + 'name', + 'price', + 'quantity', + 'unit_price', + }; + + Map _sqlRow( + Map source, + Set columns, + ) { + final row = {}; + for (final column in columns) { + if (!source.containsKey(column)) continue; + row[column] = _sqlValue(source[column]); + } + return row; + } + + Object? _sqlValue(Object? value) { + if (value == null) return null; + if (value is bool) return value ? 1 : 0; + if (value is int) return value; + if (value is double) { + if (value == value.roundToDouble()) return value.toInt(); + return value; + } + if (value is num) return value.toDouble(); + return value.toString(); + } + + Future _resetSequence(DatabaseExecutor db, String table) async { + try { + final max = Sqflite.firstIntValue( + await db.rawQuery('SELECT MAX(id) FROM $table'), + ); + await db.delete('sqlite_sequence', where: 'name = ?', whereArgs: [table]); + if (max != null) { + await db.insert('sqlite_sequence', {'name': table, 'seq': max}); + } + } catch (_) { + // sqlite_sequence is missing on some empty databases. + } } } diff --git a/lib/shop_detail_screen.dart b/lib/shop_detail_screen.dart new file mode 100644 index 0000000..73677b8 --- /dev/null +++ b/lib/shop_detail_screen.dart @@ -0,0 +1,158 @@ +/// One shopping trip: receipt photo, supermarket, products and costs. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'models/shop.dart'; +import 'product_detail_screen.dart'; +import 'services/product_repository.dart'; +import 'utils/dates.dart'; +import 'utils/money.dart'; + +class ShopDetailScreen extends StatefulWidget { + const ShopDetailScreen({ + super.key, + required this.repository, + required this.shopId, + }); + + final ProductRepository repository; + final int shopId; + + @override + State createState() => _ShopDetailScreenState(); +} + +class _ShopDetailScreenState extends State { + late Future<({Shop shop, List items})> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<({Shop shop, List items})> _load() async { + final shop = await widget.repository.getShop(widget.shopId); + if (shop == null) { + throw StateError('Shop not found'); + } + final items = await widget.repository.getShopItems(widget.shopId); + return (shop: shop, items: items); + } + + Future _openReceipt(File file) async { + await showDialog( + context: context, + builder: (context) { + return Dialog( + insetPadding: const EdgeInsets.all(16), + child: InteractiveViewer( + child: Image.file(file, fit: BoxFit.contain), + ), + ); + }, + ); + } + + Future _openProduct(int productId) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProductDetailScreen( + repository: widget.repository, + productId: productId, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Shopping trip')), + body: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + 'Could not load this trip.', + style: theme.textTheme.bodyLarge, + ), + ); + } + if (!snapshot.hasData) { + 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), + children: [ + Text(shop.supermarket, style: theme.textTheme.headlineMedium), + const SizedBox(height: 4), + Text( + formatDate(shop.shoppedAt), + style: theme.textTheme.bodyLarge, + ), + if (hasReceipt) ...[ + const SizedBox(height: 16), + GestureDetector( + onTap: () => _openReceipt(receiptFile), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.file( + receiptFile, + height: 180, + width: double.infinity, + fit: BoxFit.cover, + cacheWidth: 900, + ), + ), + ), + ], + const SizedBox(height: 24), + Text('Products', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + for (final item in items) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(item.name), + subtitle: item.quantity == null + ? null + : Text( + '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', + ), + trailing: Text( + formatEuro(item.price), + style: theme.textTheme.titleMedium, + ), + onTap: () => _openProduct(item.productId), + ), + const Divider(), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text('Total', style: theme.textTheme.titleMedium), + trailing: Text( + formatEuro(shop.total), + style: theme.textTheme.titleLarge, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/shop_log_screen.dart b/lib/shop_log_screen.dart new file mode 100644 index 0000000..e22c96a --- /dev/null +++ b/lib/shop_log_screen.dart @@ -0,0 +1,159 @@ +/// Log of saved shopping trips. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'models/shop.dart'; +import 'services/app_settings.dart'; +import 'services/product_repository.dart'; +import 'shop_detail_screen.dart'; +import 'utils/dates.dart'; +import 'utils/money.dart'; +import 'widgets/settings_button.dart'; +import 'widgets/store_tag.dart'; + +class ShopLogScreen extends StatefulWidget { + const ShopLogScreen({super.key, required this.repository}); + + final ProductRepository repository; + + @override + State createState() => _ShopLogScreenState(); +} + +class _ShopLogScreenState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = widget.repository.getShops(); + } + + void _reload() { + setState(() => _future = widget.repository.getShops()); + } + + Future _openShop(Shop shop) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ShopDetailScreen( + repository: widget.repository, + shopId: shop.id, + ), + ), + ); + if (mounted) _reload(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const []; + + return Scaffold( + appBar: AppBar( + title: const Text('Shop log'), + actions: const [SettingsButton()], + ), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + 'Could not load the shop log.', + style: theme.textTheme.bodyLarge, + ), + ); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final shops = snapshot.data!; + if (shops.isEmpty) { + return Center( + child: Text( + 'Scan a receipt to log a shopping trip.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ); + } + + 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), + ), + subtitle: Text( + [ + formatDate(shop.shoppedAt), + count == 1 ? '1 product' : '$count products', + ].join(' · '), + ), + trailing: Text( + formatEuro(shop.total), + style: theme.textTheme.titleMedium, + ), + onTap: () => _openShop(shop), + ); + }, + ); + }, + ), + ); + } +} + +class _ReceiptThumb extends StatelessWidget { + const _ReceiptThumb({this.path}); + + final String? path; + + @override + Widget build(BuildContext context) { + final file = path == null ? null : File(path!); + final hasFile = file != null && file.existsSync(); + final scheme = Theme.of(context).colorScheme; + + 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, + ), + ), + ), + ); + } +} diff --git a/lib/utils/categories.dart b/lib/utils/categories.dart new file mode 100644 index 0000000..37e29c5 --- /dev/null +++ b/lib/utils/categories.dart @@ -0,0 +1,29 @@ +/// Grocery categories a product can be assigned to. +library; + +const List kDefaultCategories = [ + 'Fruit & vegetables', + 'Dairy & eggs', + 'Meat & fish', + 'Bread & bakery', + 'Pantry', + 'Drinks', + 'Frozen', + 'Snacks', + 'Household', + 'Personal care', +]; + +List mergeCategories(Iterable used) { + final seen = {}; + final result = []; + for (final name in [...kDefaultCategories, ...used]) { + final trimmed = name.trim(); + if (trimmed.isEmpty) continue; + final key = trimmed.toLowerCase(); + if (seen.contains(key)) continue; + seen.add(key); + result.add(trimmed); + } + return result; +} diff --git a/lib/utils/dates.dart b/lib/utils/dates.dart new file mode 100644 index 0000000..fed2a02 --- /dev/null +++ b/lib/utils/dates.dart @@ -0,0 +1,20 @@ +/// Date formatting for shop log and product history. +library; + +const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +String formatDate(DateTime date) => + '${date.day} ${_months[date.month - 1]} ${date.year}'; diff --git a/lib/utils/fuzzy.dart b/lib/utils/fuzzy.dart new file mode 100644 index 0000000..ec4987b --- /dev/null +++ b/lib/utils/fuzzy.dart @@ -0,0 +1,40 @@ +/// Lightweight fuzzy matching for product search. +library; + +String _normalize(String value) => + value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + +/// True when [query] fuzzily matches [text]. +bool fuzzyMatch(String query, String text) { + final q = _normalize(query); + if (q.isEmpty) return true; + final t = _normalize(text); + if (t.contains(q)) return true; + + for (final word in q.split(' ')) { + if (word.isEmpty) continue; + if (!t.contains(word) && !_subsequence(word, t)) return false; + } + if (q.contains(' ')) return true; + return _subsequence(q, t); +} + +/// Higher is better. Null when [query] does not match. +int? fuzzyScore(String query, String text) { + final q = _normalize(query); + if (q.isEmpty) return 0; + final t = _normalize(text); + if (t == q) return 1000; + if (t.startsWith(q)) return 800; + if (t.contains(q)) return 600 - t.indexOf(q); + if (!fuzzyMatch(q, t)) return null; + return 200 - (t.length - q.length).abs().clamp(0, 150); +} + +bool _subsequence(String query, String text) { + var i = 0; + for (var c = 0; c < text.length && i < query.length; c++) { + if (text.codeUnitAt(c) == query.codeUnitAt(i)) i++; + } + return i == query.length; +} diff --git a/lib/utils/supermarkets.dart b/lib/utils/supermarkets.dart new file mode 100644 index 0000000..53e53fd --- /dev/null +++ b/lib/utils/supermarkets.dart @@ -0,0 +1,99 @@ +/// Known Dutch supermarket names, default colors, and OCR detection. +library; + +import '../models/supermarket.dart'; + +const String kOtherSupermarket = 'Other'; + +final List kDefaultSupermarkets = [ + const Supermarket(name: 'Albert Heijn', colorValue: 0xFF00A0E2, sortOrder: 0), + const Supermarket(name: 'Jumbo', colorValue: 0xFFEEC21B, sortOrder: 1), + const Supermarket(name: 'Lidl', colorValue: 0xFF0050AA, sortOrder: 2), + const Supermarket(name: 'Aldi', colorValue: 0xFF00205B, sortOrder: 3), + const Supermarket(name: 'Plus', colorValue: 0xFF6EC31E, sortOrder: 4), + const Supermarket(name: 'Dirk', colorValue: 0xFFE30613, sortOrder: 5), + const Supermarket(name: 'Coop', colorValue: 0xFFE30613, sortOrder: 6), + const Supermarket(name: 'SPAR', colorValue: 0xFF009640, sortOrder: 7), + const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8), + const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9), + const Supermarket(name: 'Picnic', colorValue: 0xFFE31C5F, sortOrder: 10), + const Supermarket(name: kOtherSupermarket, colorValue: 0xFF78909C, sortOrder: 11), +]; + +const Map> kSupermarketAliases = { + 'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'], + 'Jumbo': ['JUMBO'], + 'Lidl': ['LIDL'], + 'Aldi': ['ALDI'], + 'Plus': ['PLUS'], + 'Dirk': ['DIRK'], + 'Coop': ['COOP'], + 'SPAR': ['SPAR'], + 'Nettorama': ['NETTORAMA'], + 'Hoogvliet': ['HOOGVLIET'], + 'Picnic': ['PICNIC'], +}; + +/// Names used before settings were user-editable. +const List kSupermarkets = [ + 'Albert Heijn', + 'Jumbo', + 'Lidl', + 'Aldi', + 'Plus', + 'Dirk', + 'Coop', + 'SPAR', + 'Nettorama', + 'Hoogvliet', + 'Picnic', + 'Other', +]; + +/// Returns a supermarket name if it appears in [rawText]. +String? detectSupermarket( + String rawText, { + Iterable names = const [], +}) { + final upper = rawText.toUpperCase(); + final extra = names + .map((name) => name.trim()) + .where((name) => name.isNotEmpty && name != kOtherSupermarket) + .toList() + ..sort((a, b) => b.length.compareTo(a.length)); + + for (final name in extra) { + if (RegExp('\\b${RegExp.escape(name)}\\b', caseSensitive: false) + .hasMatch(rawText)) { + return name; + } + } + + for (final entry in kSupermarketAliases.entries) { + for (final alias in entry.value) { + if (RegExp('\\b${RegExp.escape(alias)}\\b').hasMatch(upper)) { + return _canonicalName(entry.key, extra); + } + } + } + + if (RegExp(r'\bAH\b').hasMatch(upper)) { + return _canonicalName('Albert Heijn', extra); + } + return null; +} + +String _canonicalName(String fallback, List names) { + for (final name in names) { + if (name.toUpperCase() == fallback.toUpperCase()) return name; + } + return fallback; +} + +Supermarket? supermarketByName(Iterable stores, String? name) { + if (name == null || name.isEmpty) return null; + for (final store in stores) { + if (store.name.toLowerCase() == name.toLowerCase()) return store; + } + return null; +} diff --git a/lib/widgets/price_chart.dart b/lib/widgets/price_chart.dart new file mode 100644 index 0000000..7e90f19 --- /dev/null +++ b/lib/widgets/price_chart.dart @@ -0,0 +1,159 @@ +/// Simple line chart of unit prices over time. +library; + +import 'package:flutter/material.dart'; + +import '../models/purchase.dart'; +import '../utils/dates.dart'; +import '../utils/money.dart'; + +class PriceChart extends StatelessWidget { + const PriceChart({super.key, required this.purchases}); + + final List purchases; + + @override + Widget build(BuildContext context) { + if (purchases.isEmpty) { + return const SizedBox.shrink(); + } + + final points = purchases.reversed.toList(); + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 160, + child: CustomPaint( + painter: _PriceChartPainter( + prices: [ + for (final purchase in points) purchase.unitOrLinePrice, + ], + lineColor: theme.colorScheme.primary, + fillColor: theme.colorScheme.primary.withValues(alpha: 0.12), + gridColor: theme.colorScheme.outlineVariant, + labelColor: theme.colorScheme.onSurfaceVariant, + ), + child: const SizedBox.expand(), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + formatDate(points.first.boughtAt), + style: theme.textTheme.labelMedium, + ), + Text( + formatDate(points.last.boughtAt), + style: theme.textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Latest ${formatEuro(points.last.unitOrLinePrice)}', + style: theme.textTheme.labelMedium, + ), + ], + ); + } +} + +class _PriceChartPainter extends CustomPainter { + _PriceChartPainter({ + required this.prices, + required this.lineColor, + required this.fillColor, + required this.gridColor, + required this.labelColor, + }); + + final List prices; + final Color lineColor; + final Color fillColor; + final Color gridColor; + final Color labelColor; + + @override + void paint(Canvas canvas, Size size) { + final minPrice = prices.reduce((a, b) => a < b ? a : b); + final maxPrice = prices.reduce((a, b) => a > b ? a : b); + final span = (maxPrice - minPrice).abs() < 0.01 ? 1.0 : maxPrice - minPrice; + const left = 36.0; + 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 gridPaint = Paint() + ..color = gridColor + ..strokeWidth = 1; + for (var i = 0; i < 3; i++) { + final y = chart.top + chart.height * i / 2; + canvas.drawLine(Offset(chart.left, y), Offset(chart.right, y), gridPaint); + } + + final path = Path(); + final fill = Path(); + Offset? last; + for (var i = 0; i < prices.length; i++) { + final x = prices.length == 1 + ? chart.center.dx + : chart.left + chart.width * i / (prices.length - 1); + final y = chart.bottom - chart.height * ((prices[i] - minPrice) / span); + final point = Offset(x, y); + last = point; + if (i == 0) { + path.moveTo(x, y); + fill.moveTo(x, chart.bottom); + fill.lineTo(x, y); + } else { + path.lineTo(x, y); + fill.lineTo(x, y); + } + } + if (last != null) { + fill.lineTo(last.dx, chart.bottom); + fill.close(); + } + + canvas.drawPath(fill, Paint()..color = fillColor); + canvas.drawPath( + path, + Paint() + ..color = lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + + final dot = Paint()..color = lineColor; + for (var i = 0; i < prices.length; i++) { + final x = prices.length == 1 + ? chart.center.dx + : chart.left + chart.width * i / (prices.length - 1); + final y = chart.bottom - chart.height * ((prices[i] - minPrice) / span); + canvas.drawCircle(Offset(x, y), 3.5, dot); + } + + final labels = [maxPrice, (minPrice + maxPrice) / 2, minPrice]; + final textStyle = TextStyle(color: labelColor, fontSize: 10); + for (var i = 0; i < labels.length; i++) { + final tp = TextPainter( + text: TextSpan(text: labels[i].toStringAsFixed(2), style: textStyle), + textDirection: TextDirection.ltr, + )..layout(maxWidth: left - 4); + final y = chart.top + chart.height * i / 2 - tp.height / 2; + tp.paint(canvas, Offset(0, y)); + } + } + + @override + bool shouldRepaint(covariant _PriceChartPainter oldDelegate) => + oldDelegate.prices != prices || oldDelegate.lineColor != lineColor; +} diff --git a/lib/widgets/product_image.dart b/lib/widgets/product_image.dart new file mode 100644 index 0000000..b8bfb83 --- /dev/null +++ b/lib/widgets/product_image.dart @@ -0,0 +1,80 @@ +/// Thumbnail or letter avatar for a product. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +class ProductImage extends StatelessWidget { + const ProductImage({ + super.key, + required this.name, + this.path, + this.size = 48, + }); + + final String name; + final String? path; + final double size; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final file = path == null ? null : File(path!); + final hasFile = file != null && file.existsSync(); + + return ClipRRect( + borderRadius: BorderRadius.circular(size / 4), + child: SizedBox( + width: size, + height: size, + child: hasFile + ? Image.file( + file, + fit: BoxFit.cover, + cacheWidth: (size * 3).round(), + errorBuilder: (_, _, _) => _LetterAvatar( + name: name, + size: size, + colorScheme: theme.colorScheme, + ), + ) + : _LetterAvatar( + name: name, + size: size, + colorScheme: theme.colorScheme, + ), + ), + ); + } +} + +class _LetterAvatar extends StatelessWidget { + const _LetterAvatar({ + required this.name, + required this.size, + required this.colorScheme, + }); + + final String name; + final double size; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final trimmed = name.trim(); + final letter = trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + return ColoredBox( + color: colorScheme.secondaryContainer, + child: Center( + child: Text( + letter, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSecondaryContainer, + fontSize: size * 0.4, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings_button.dart b/lib/widgets/settings_button.dart new file mode 100644 index 0000000..32e88c4 --- /dev/null +++ b/lib/widgets/settings_button.dart @@ -0,0 +1,372 @@ +/// Gear that opens the settings sheet. +library; + +import 'package:flutter/material.dart'; + +import '../models/supermarket.dart'; +import '../services/app_settings.dart'; +import '../services/backup_service.dart'; + +class SettingsButton extends StatelessWidget { + const SettingsButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: 'Settings', + onPressed: () => showSettingsSheet(context), + icon: const Icon(Icons.settings_outlined), + ); + } +} + +Future showSettingsSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return const _SettingsSheet(); + }, + ); +} + +class _SettingsSheet extends StatefulWidget { + const _SettingsSheet(); + + @override + State<_SettingsSheet> createState() => _SettingsSheetState(); +} + +class _SettingsSheetState extends State<_SettingsSheet> { + bool _busy = false; + + Future _export(AppSettings settings) async { + setState(() => _busy = true); + try { + final shared = await BackupService(settings.repository).exportBackup(); + if (!mounted) return; + if (shared) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Backup ready.')), + ); + } + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not export the backup.')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _import(AppSettings settings) async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Import backup?'), + content: const Text( + 'This replaces all products, shopping trips, photos, and settings on this device.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Import'), + ), + ], + ); + }, + ); + if (confirmed != true || !mounted) return; + + setState(() => _busy = true); + try { + final imported = await BackupService(settings.repository).importBackup(); + if (!mounted) return; + if (imported) { + await settings.reload(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Backup imported.')), + ); + } + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not import this backup.')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final settings = SettingsScope.of(context); + final bottom = MediaQuery.viewInsetsOf(context).bottom; + + return ListenableBuilder( + listenable: settings, + 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, + ), + 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(height: 8), + OutlinedButton.icon( + onPressed: _busy + ? null + : () => _editStore(context, settings, null), + icon: const Icon(Icons.add), + label: const Text('Add supermarket'), + ), + ], + ), + ), + ); + }, + ); + } + + 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_tag.dart b/lib/widgets/store_tag.dart new file mode 100644 index 0000000..eb23551 --- /dev/null +++ b/lib/widgets/store_tag.dart @@ -0,0 +1,67 @@ +/// Colored supermarket chip used in lists and on product pages. +library; + +import 'package:flutter/material.dart'; + +import '../models/supermarket.dart'; +import '../utils/supermarkets.dart'; + +class StoreTag extends StatelessWidget { + const StoreTag({ + super.key, + required this.name, + this.stores = const [], + this.compact = false, + }); + + final String? name; + final List stores; + final bool compact; + + @override + Widget build(BuildContext context) { + final storeName = name?.trim(); + if (storeName == null || storeName.isEmpty) { + return const SizedBox.shrink(); + } + + final store = supermarketByName(stores, storeName); + final color = store?.color ?? const Color(0xFF78909C); + final onColor = color.computeLuminance() > 0.55 + ? Colors.black87 + : Colors.white; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 6 : 8, + vertical: compact ? 2 : 4, + ), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: compact ? 6 : 8, + height: compact ? 6 : 8, + decoration: BoxDecoration( + color: onColor.withValues(alpha: 0.85), + shape: BoxShape.circle, + ), + ), + SizedBox(width: compact ? 4 : 6), + Text( + storeName, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: onColor, + fontWeight: FontWeight.w600, + fontSize: compact ? 11 : 12, + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 3ca6224..66422ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "1f111ed6bb33724ba782cc86357e3fd97f57051d55fc61adf33dcfc5049a1581" + url: "https://pub.dev" + source: hosted + version: "1.0.3" args: dependency: transitive description: @@ -81,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" fake_async: dependency: transitive description: @@ -97,6 +113,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: b7acb5d123cb398f6b4a8a38e43777545254f8fc1fabef3ee11cbbb56aece9c8 + url: "https://pub.dev" + source: hosted + version: "12.1.2" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "6e7cae501d48fd57a27911608db718ebd898e6ccf934bf09e33a8c0d0365bec0" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: f0f01ed42967b7355f6f25c8b121ea531d1948e2a9b4b44f4b4de8489d7b04ae + url: "https://pub.dev" + source: hosted + version: "1.0.2" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "11ef1b5c14d9186b4788cc273dd8d5cb81bca847145060991a28234ff7916fc1" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: df472142f63c4557fdfbb375c81454ca1c251491069eefcdc6a866bc12f8750b + url: "https://pub.dev" + source: hosted + version: "3.0.3" file_selector_linux: dependency: transitive description: @@ -129,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -432,6 +512,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -464,6 +552,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" sky_engine: dependency: transitive description: flutter @@ -573,6 +677,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: @@ -597,6 +741,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "225f58e64c15c2d7b34fb8faf3ca188831967f26b5a723d7c3a7969e41c9b5a5" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xdg_directories: dependency: transitive description: @@ -605,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1af5403..2540897 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,8 @@ dependencies: path: ^1.9.1 path_provider: ^2.1.5 sqflite: ^2.4.2 + file_picker: ^12.1.2 + share_plus: ^13.3.0 dev_dependencies: flutter_test: diff --git a/test/backup_format_test.dart b/test/backup_format_test.dart new file mode 100644 index 0000000..87c9ee7 --- /dev/null +++ b/test/backup_format_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:receipity/services/backup_format.dart'; + +void main() { + test('round-trips a backup document', () { + final original = const BackupDocument( + version: 1, + exportedAt: '2026-08-29T12:00:00.000', + darkMode: true, + supermarkets: [ + {'id': 1, 'name': 'Jumbo', 'color': 0xFFEEC21B, 'sort_order': 0}, + ], + products: [ + { + 'id': 1, + 'name': 'Milk', + 'normalized_name': 'MILK', + 'last_price': 1.5, + 'times_seen': 2, + 'favourite': 1, + 'image_path': 'products/product_1.jpg', + 'supermarket': 'Jumbo', + 'notes': 'Organic', + 'created_at': '2026-08-29T12:00:00.000', + 'updated_at': '2026-08-29T12:00:00.000', + }, + ], + shops: [ + { + 'id': 8, + 'supermarket': 'Jumbo', + 'shopped_at': '2026-08-29T12:00:00.000', + 'receipt_image_path': 'receipts/shop_8.jpg', + 'total': 1.5, + 'created_at': '2026-08-29T12:00:00.000', + }, + ], + shopItems: [ + { + 'id': 1, + 'shop_id': 8, + 'product_id': 1, + 'name': 'Milk', + 'price': 1.5, + 'quantity': 1, + 'unit_price': 1.5, + }, + ], + images: {'products/product_1.jpg': 'abc123'}, + ); + + final parsed = BackupDocument.parse(original.encode()); + expect(parsed.darkMode, isTrue); + expect(parsed.products.single['name'], 'Milk'); + expect(parsed.shops.single['id'], 8); + expect(parsed.shopItems.single['product_id'], 1); + expect(parsed.images['products/product_1.jpg'], 'abc123'); + }); + + test('rejects a file that is not a Receipity backup', () { + expect( + () => BackupDocument.parse('{"hello":"world"}'), + throwsFormatException, + ); + }); + + test('maps stored photo paths to backup keys', () { + expect( + backupImageKey('/data/app_flutter/products/product_12.jpg'), + 'products/product_12.jpg', + ); + expect( + backupImageKey('/data/app_flutter/receipts/shop_3.jpg'), + 'receipts/shop_3.jpg', + ); + expect(backupImageKey(null), isNull); + }); +} diff --git a/test/fuzzy_test.dart b/test/fuzzy_test.dart new file mode 100644 index 0000000..fabdbfb --- /dev/null +++ b/test/fuzzy_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:receipity/utils/fuzzy.dart'; + +void main() { + test('matches substrings and character subsequences', () { + expect(fuzzyMatch('melk', 'JUMBO BIO VOLLE MELK'), isTrue); + expect(fuzzyMatch('jb melk', 'JUMBO BIO VOLLE MELK'), isTrue); + expect(fuzzyMatch('aard', 'AARDBEIEN'), isTrue); + expect(fuzzyMatch('xyz', 'AARDBEIEN'), isFalse); + }); + + test('ranks closer matches higher', () { + expect(fuzzyScore('melk', 'MELK')! > fuzzyScore('melk', 'JUMBO BIO VOLLE MELK')!, isTrue); + }); +} diff --git a/test/supermarket_test.dart b/test/supermarket_test.dart new file mode 100644 index 0000000..9dc3eeb --- /dev/null +++ b/test/supermarket_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:receipity/utils/supermarkets.dart'; + +void main() { + test('detects Jumbo and Lidl from receipt text', () { + expect(detectSupermarket('Jumbo\nMelk 1,50'), 'Jumbo'); + expect( + detectSupermarket('Dekamarkt\nMelk 1,50', names: ['Dekamarkt']), + 'Dekamarkt', + ); + expect(detectSupermarket('LIDL SUPERMARKT\nBananas 1.20'), 'Lidl'); + }); + + test('detects Albert Heijn aliases', () { + expect(detectSupermarket('Albert Heijn\nKaas 2,00'), 'Albert Heijn'); + expect(detectSupermarket('AH TO GO\nCoffee 1,80'), 'Albert Heijn'); + expect(detectSupermarket('AH\nMilk 1.00'), 'Albert Heijn'); + }); + + test('returns null when no supermarket is present', () { + expect(detectSupermarket('Milk 1.50\nBread 2.00'), isNull); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 765bcfa..3a92bc3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -6,7 +6,9 @@ import 'package:receipity/review_screen.dart'; import 'package:receipity/services/product_repository.dart'; void main() { - testWidgets('scan screen shows capture actions', (tester) async { + testWidgets('scan screen shows capture actions and bottom navigation', ( + tester, + ) async { await tester.pumpWidget(ReceipityApp()); expect(find.text('Receipity'), findsOneWidget); @@ -17,6 +19,9 @@ void main() { find.textContaining('Take a photo of a shopping receipt'), findsOneWidget, ); + expect(find.text('Products'), findsOneWidget); + expect(find.text('Scan'), findsOneWidget); + expect(find.text('Shop log'), findsOneWidget); }); testWidgets('review screen lists items and can delete one', (tester) async { @@ -28,7 +33,7 @@ void main() { LineItem(id: '2', name: 'Bread', price: 2), ], rawText: 'Milk 1.50\nBread 2.00', - repository: ProductRepository(), + repository: ProductRepository(databasePath: ':memory:'), ), ), ); @@ -36,11 +41,28 @@ void main() { expect(find.text('Milk'), findsOneWidget); expect(find.text('Bread'), findsOneWidget); expect(find.text('Add item'), findsOneWidget); - expect(find.text('Confirm'), findsOneWidget); + expect(find.textContaining('Save trip'), findsOneWidget); + expect(find.text('Supermarket'), findsOneWidget); await tester.drag(find.text('Milk'), const Offset(-500, 0)); await tester.pumpAndSettle(); expect(find.text('Milk'), findsNothing); expect(find.text('Bread'), findsOneWidget); }); + + testWidgets('review screen preselects a supermarket from OCR text', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: ReviewScreen( + items: [LineItem(id: '1', name: 'Milk', price: 1.5)], + rawText: 'Jumbo\nMilk 1.50', + repository: ProductRepository(databasePath: ':memory:'), + ), + ), + ); + + expect(find.text('Jumbo'), findsWidgets); + }); }