diff --git a/docs/receipt-parsing.md b/docs/receipt-parsing.md new file mode 100644 index 0000000..11a0842 --- /dev/null +++ b/docs/receipt-parsing.md @@ -0,0 +1,168 @@ +# Receipt parsing (implementation) + +This is the working guide for Receipity's receipt parser. Use it when a new +shop's receipt does not parse well. + +The original design notes live in [`receipt-parsing-spec.md`](../receipt-parsing-spec.md). +Keywords and regexes live in [`receipt-parsing-keywords.json`](../receipt-parsing-keywords.json) +and are bundled as a Flutter asset. + +--- + +## What the parser produces + +For each receipt: + +| Field | Meaning | +|---|---| +| `items[].name` | Product name | +| `items[].quantity` | Pack count when `N X unit` was found (otherwise omitted) | +| `items[].unitPrice` | Price of one pack when quantity is set | +| `items[].discount` | Negative amount folded into the line (promos) | +| `items[].price` | **Line total after discount** (this is what the trip saves) | +| `store` | Best-effort store from the header + `known_stores` | +| `receiptTotal` | First grand-total amount after the items | +| `totalDiscount` | Receipt-level `Totaal korting` (and similar) | +| `validationPassed` | Item sum matches `receiptTotal` within €0.01 | + +If validation fails, Review shows: *Parsed totals do not match the receipt.* + +--- + +## Pipeline + +1. **Preprocess** each OCR line: trim, collapse spaces, drop empty lines. +2. **Choose a layout** + - **Sequential** (default): name, price, quantity and discount appear in + reading order. Jumbo (printed), Lidl, Kruidvat, World Toko. + - **Column OCR fallback:** ML Kit sometimes dumps every name, then every + amount. Detected when there is a run of 4+ price-only lines *and* enough + name-like lines. Used for the Jumbo sample dump in tests. +3. **Classify** each line (first match wins) using the keyword JSON. +4. **Build items** (pending name, then quantity/discount/price). +5. **Validate** against the receipt total when one was found. + +Do **not** treat weight in the name (`900g`, `500ml`) as quantity. Only `N X` +next to a price is a quantity. + +--- + +## Line types + +Priority is the same as the spec: + +1. Separators / column headers (`OMSCHRIJVING`, `Artikel`, `Prijs`, …) +2. Tax / BTW tables (`BTW`, `Bedr.Excl`, `B 9%`) +3. Payment (`Betaald`, `VISA`, `PIN`, …) — also matches OCR `Betaal d:` +4. Noise / footer (URLs, opening hours, thank-you lines) +5. Receipt-level discount total (`Totaal korting`) +6. Subtotal / tax-inclusive total (ignored for items) +7. Grand total (`Totaal`, `Total`) — stops sequential item parsing when an + amount is on the same line +8. Discount (`ACTIE`, `KORTING`, `In prijs verlaagd`, or a negative amount) +9. Quantity modifier (`2 X 2,79`, optional `PER STUK`, optional line total) +10. Item (name + price at end; optional tax letter `B`/`C` is stripped) +11. Price-only +12. Name-only (starts a pending item — Jumbo multi-pack) + +### Quantity variants + +| Variant | Example | Store | +|---|---|---| +| **A** — name, then `N X unit` | `AARDBEIEN` / `2 X 3,99` | Jumbo, World Toko | +| **B** — name and `N X unit` on one line | `ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK 7,98` | Kruidvat | + +The JSON standalone quantity regex is extended in code so a right-hand +line total is allowed: `2 X 2,79 5,58`. + +### Discounts + +A discount is attached to the **current / previous item** and subtracted from +`price`. Receipt-wide totals (`Totaal korting`) are not products. + +--- + +## How to add a new shop + +Work in this order. You usually only need step 1. + +### 1. Keywords (`receipt-parsing-keywords.json`) + +| Add to | When | +|---|---| +| `known_stores.list` | Header contains a new banner name | +| `column_header_keywords.list` | New table titles (`Omschrijving`, `Amount`, …) | +| `discount_keywords.list` | Promo wording (`Bonus`, `Sparing`, …) | +| `total_keywords.*` | Other words for subtotal / grand total | +| `payment_keywords.list` | Card brands, `Betaald met …` | +| `tax_breakdown_keywords.list` | VAT table labels | +| `noise_footer_keywords.list` | Loyalty, hours, slogans that were parsed as items | +| `quantity_modifier_pattern` | Only if `N X` looks different (`2x`, `2 *`, `à`) | + +Matching rules implemented in code (not only the JSON comment): + +- Keywords of **3 characters or fewer** use a **word boundary** (`PIN` must + not match `SPINAZIE`). +- Column headers and item-count lines must **be the whole line** (so `Prijs` + as a header does not hide Lidl's `In prijs verlaagd`). + +After editing the JSON, rebuild the app (it is loaded as an asset at startup). +Tests load the same file from the project root. + +### 2. Store quirks table + +Add a row here **and** in `receipt-parsing-spec.md` section 6: + +| Store | Layout | Notes | +|---|---|---| +| Jumbo | A | `ACTIE ` + negative amount. Tax letter `B`/`C` far right. OCR may split names vs amounts → column fallback. | +| Lidl | sequential | `In prijs verlaagd -0,50`. Ignore `Bedr.Excl` / `B 9%` rows. | +| Kruidvat | B | `N X price PER STUK` on the product line. `KORTING …` may be one line later. | +| World Toko | A / simple | `Artikel` / `Prijs` headers. Weight in the name is not quantity. | +| *(new shop)* | A / B / columns | Short description of the odd lines | + +### 3. Canonical store name + +If OCR should pre-select the supermarket on Review: + +- Add the banner string to `known_stores.list` +- Add an alias in `lib/utils/supermarkets.dart` (`kSupermarketAliases`) +- Map the uppercase banner in `ReceiptParser._canonicalStore` + +### 4. Fixture test + +1. Scan a real receipt, copy **Raw OCR text** from Review. +2. Add a test in `test/receipt_parser_test.dart` with that dump. +3. Assert product names, a couple of prices, quantity lines, and that headers / + totals / payment lines are **not** items. + +Keep the raw dump in the test (or a `test/fixtures/` file) so the next person +can see the real layout. + +### 5. Only then change parser code + +Change `lib/services/receipt_parser.dart` if the new layout is a **new +structure** (not just new words): extra columns, quantity written as +`2 stuks à 1,50`, discounts *above* the product, etc. + +Prefer a small, documented branch over a one-off special case for one shop. + +--- + +## Files + +| File | Role | +|---|---| +| `receipt-parsing-keywords.json` | Editable keyword / regex config | +| `receipt-parsing-spec.md` | Design: classification order, item algorithm | +| `lib/services/receipt_parse_config.dart` | Loads JSON, keyword matching | +| `lib/services/receipt_parser.dart` | Classify → items → validate | +| `test/receipt_parser_test.dart` | Layout fixtures | + +--- + +## Out of scope (parser) + +- Product categories +- Matching names to the existing catalog +- Translating receipts; add another keyword list if you need a second language diff --git a/lib/crop_receipt_screen.dart b/lib/crop_receipt_screen.dart new file mode 100644 index 0000000..63ca973 --- /dev/null +++ b/lib/crop_receipt_screen.dart @@ -0,0 +1,181 @@ +/// Drag a crop box over the receipt photo before OCR runs. +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crop_your_image/crop_your_image.dart'; +import 'package:flutter/material.dart'; + +class CropReceiptScreen extends StatefulWidget { + const CropReceiptScreen({super.key, required this.imagePath}); + + final String imagePath; + + @override + State createState() => _CropReceiptScreenState(); +} + +class _CropReceiptScreenState extends State { + final CropController _controller = CropController(); + + Uint8List? _image; + String? _error; + bool _ready = false; + bool _cropping = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final bytes = await File(widget.imagePath).readAsBytes(); + if (!mounted) return; + setState(() => _image = bytes); + } catch (_) { + if (!mounted) return; + setState(() => _error = 'Could not open this photo.'); + } + } + + void _onCropped(CropResult result) { + switch (result) { + case CropSuccess(:final croppedImage): + _saveAndPop(croppedImage); + case CropFailure(): + if (!mounted) return; + setState(() => _cropping = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not crop this photo.')), + ); + } + } + + Future _saveAndPop(Uint8List bytes) async { + try { + final file = File( + '${Directory.systemTemp.path}/receipity_crop_${DateTime.now().millisecondsSinceEpoch}.jpg', + ); + await file.writeAsBytes(bytes, flush: true); + if (!mounted) return; + Navigator.of(context).pop(file.path); + } catch (_) { + if (!mounted) return; + setState(() => _cropping = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not save the cropped photo.')), + ); + } + } + + void _confirm() { + if (!_ready || _cropping) return; + setState(() => _cropping = true); + _controller.crop(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return PopScope( + canPop: !_cropping, + child: Scaffold( + appBar: AppBar(title: const Text('Crop receipt')), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _editor(theme)), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Drag the corners over the text you want to read. Pinch to zoom.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _ready && !_cropping ? _confirm : null, + icon: const Icon(Icons.check), + label: Text(_cropping ? 'Cropping…' : 'Use this area'), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _editor(ThemeData theme) { + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _error!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ), + ); + } + + final image = _image; + if (image == null) { + return const Center(child: CircularProgressIndicator()); + } + + return Stack( + fit: StackFit.expand, + children: [ + Crop( + image: image, + controller: _controller, + onCropped: _onCropped, + interactive: true, + filterQuality: FilterQuality.medium, + baseColor: theme.colorScheme.surface, + maskColor: Colors.black.withValues(alpha: 0.55), + radius: 8, + progressIndicator: const Center(child: CircularProgressIndicator()), + initialRectBuilder: InitialRectBuilder.withBuilder(( + viewportRect, + imageRect, + ) { + final dx = imageRect.width * 0.06; + final dy = imageRect.height * 0.06; + return Rect.fromLTRB( + imageRect.left + dx, + imageRect.top + dy, + imageRect.right - dx, + imageRect.bottom - dy, + ); + }), + cornerDotBuilder: (size, _) => + DotControl(color: theme.colorScheme.primary), + onStatusChanged: (status) { + final ready = status == CropStatus.ready; + if (ready == _ready) return; + setState(() => _ready = ready); + }, + ), + if (_cropping) + const ColoredBox( + color: Color(0x66000000), + child: Center(child: CircularProgressIndicator()), + ), + ], + ); + } +} diff --git a/lib/home_shell.dart b/lib/home_shell.dart index 9fcd5d6..cce7c93 100644 --- a/lib/home_shell.dart +++ b/lib/home_shell.dart @@ -1,4 +1,4 @@ -/// Bottom navigation: products, scan, and shop log. +/// Bottom navigation: products, lists, add, and shop log. library; import 'package:flutter/material.dart'; @@ -8,6 +8,7 @@ import 'scan_screen.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'shop_log_screen.dart'; +import 'shopping_lists_screen.dart'; class HomeShell extends StatefulWidget { const HomeShell({super.key, required this.repository}); @@ -19,10 +20,10 @@ class HomeShell extends StatefulWidget { } class _HomeShellState extends State { - static const _scanIndex = 1; - static const _shopLogIndex = 2; + static const _addIndex = 2; + static const _shopLogIndex = 3; - int _index = _scanIndex; + int _index = _addIndex; int _revision = 0; AppSettings? _settings; int _seenGeneration = 0; @@ -59,9 +60,8 @@ class _HomeShellState extends State { }); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Shopping trip saved.')), - ); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Shopping trip saved.'))); }); } @@ -70,17 +70,21 @@ class _HomeShellState extends State { return Scaffold( body: switch (_index) { 0 => ProductsScreen( - key: ValueKey('products-$_revision'), - repository: widget.repository, - ), - 1 => ScanScreen( - repository: widget.repository, - onTripSaved: _onTripSaved, - ), + key: ValueKey('products-$_revision'), + repository: widget.repository, + ), + 1 => ShoppingListsScreen( + key: ValueKey('lists-$_revision'), + repository: widget.repository, + ), + 2 => ScanScreen( + repository: widget.repository, + onTripSaved: _onTripSaved, + ), _ => ShopLogScreen( - key: ValueKey('shops-$_revision'), - repository: widget.repository, - ), + key: ValueKey('shops-$_revision'), + repository: widget.repository, + ), }, bottomNavigationBar: NavigationBar( selectedIndex: _index, @@ -92,9 +96,14 @@ class _HomeShellState extends State { label: 'Products', ), NavigationDestination( - icon: Icon(Icons.photo_camera_outlined), - selectedIcon: Icon(Icons.photo_camera), - label: 'Scan', + icon: Icon(Icons.checklist_outlined), + selectedIcon: Icon(Icons.checklist), + label: 'Lists', + ), + NavigationDestination( + icon: Icon(Icons.add), + selectedIcon: Icon(Icons.add_circle), + label: 'Add', ), NavigationDestination( icon: Icon(Icons.receipt_long_outlined), diff --git a/lib/main.dart b/lib/main.dart index a3ff2a1..dc62225 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,17 +6,19 @@ import 'package:flutter/material.dart'; import 'home_shell.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; +import 'services/receipt_parser.dart'; import 'utils/app_theme.dart'; import 'utils/constants.dart'; -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); + await ReceiptParser.loadConfig(); runApp(ReceipityApp()); } class ReceipityApp extends StatefulWidget { ReceipityApp({super.key, ProductRepository? repository}) - : repository = repository ?? ProductRepository(); + : repository = repository ?? ProductRepository(); final ProductRepository repository; diff --git a/lib/models/line_item.dart b/lib/models/line_item.dart index 14a52be..767eb53 100644 --- a/lib/models/line_item.dart +++ b/lib/models/line_item.dart @@ -8,20 +8,27 @@ class LineItem { required this.price, this.quantity, this.unitPrice, + this.discount = 0, }); final String id; final String name; + + /// Line total after any discount. final double price; final int? quantity; final double? unitPrice; + /// Negative amount when a discount was folded into [price]. + final double discount; + LineItem copyWith({ String? id, String? name, double? price, int? quantity, double? unitPrice, + double? discount, bool clearQuantity = false, }) { return LineItem( @@ -30,6 +37,7 @@ class LineItem { price: price ?? this.price, quantity: clearQuantity ? null : (quantity ?? this.quantity), unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice), + discount: discount ?? this.discount, ); } } diff --git a/lib/models/shopping_list.dart b/lib/models/shopping_list.dart new file mode 100644 index 0000000..6c3756e --- /dev/null +++ b/lib/models/shopping_list.dart @@ -0,0 +1,57 @@ +/// A named list of things to buy. +library; + +class ShoppingList { + const ShoppingList({ + required this.id, + required this.name, + required this.itemCount, + required this.checkedCount, + required this.updatedAt, + }); + + final int id; + final String name; + final int itemCount; + final int checkedCount; + final DateTime updatedAt; + + factory ShoppingList.fromMap(Map map) { + return ShoppingList( + id: map['id']! as int, + name: map['name']! as String, + itemCount: (map['item_count'] as num?)?.toInt() ?? 0, + checkedCount: (map['checked_count'] as num?)?.toInt() ?? 0, + updatedAt: DateTime.parse(map['updated_at']! as String), + ); + } +} + +class ShoppingListItem { + const ShoppingListItem({ + required this.id, + required this.listId, + required this.name, + required this.checked, + required this.sortOrder, + this.productId, + }); + + final int id; + final int listId; + final String name; + final bool checked; + final int sortOrder; + final int? productId; + + factory ShoppingListItem.fromMap(Map map) { + return ShoppingListItem( + id: map['id']! as int, + listId: map['list_id']! as int, + name: map['name']! as String, + checked: (map['checked'] as int? ?? 0) == 1, + sortOrder: (map['sort_order'] as num?)?.toInt() ?? 0, + productId: map['product_id'] as int?, + ); + } +} diff --git a/lib/review_screen.dart b/lib/review_screen.dart index 6227ba2..026942e 100644 --- a/lib/review_screen.dart +++ b/lib/review_screen.dart @@ -9,6 +9,7 @@ import 'services/product_repository.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'utils/supermarkets.dart'; +import 'widgets/item_edit_sheet.dart'; import 'widgets/store_tag.dart'; class ReviewScreen extends StatefulWidget { @@ -19,6 +20,8 @@ class ReviewScreen extends StatefulWidget { required this.repository, this.imagePath, this.supermarketNames = kSupermarkets, + this.needsReview = false, + this.detectedStore, }); final List items; @@ -26,6 +29,8 @@ class ReviewScreen extends StatefulWidget { final ProductRepository repository; final String? imagePath; final List supermarketNames; + final bool needsReview; + final String? detectedStore; @override State createState() => _ReviewScreenState(); @@ -49,6 +54,8 @@ class _ReviewScreenState extends State { ); if (detected != null) { _supermarket = detected; + } else if (widget.detectedStore != null) { + _supermarket = widget.detectedStore; } } @@ -63,12 +70,7 @@ class _ReviewScreenState extends State { } Future _edit({LineItem? existing}) async { - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (context) => _ItemEditSheet(item: existing), - ); + final result = await showItemEditSheet(context, item: existing); if (result == null || !mounted) return; setState(() { @@ -97,9 +99,8 @@ class _ReviewScreenState extends State { Future _confirm() async { final supermarket = _resolvedSupermarket; if (supermarket == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Select a supermarket.')), - ); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Select a supermarket.'))); return; } if (_items.isEmpty) { @@ -142,7 +143,13 @@ class _ReviewScreenState extends State { } return Scaffold( - appBar: AppBar(title: const Text('Review items')), + appBar: AppBar( + title: Text( + widget.rawText.isEmpty && widget.imagePath == null + ? 'New trip' + : 'Review items', + ), + ), body: Column( children: [ Padding( @@ -150,13 +157,27 @@ class _ReviewScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (widget.needsReview) ...[ + Material( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Parsed totals do not match the receipt. Check the items before saving.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ), + const SizedBox(height: 12), + ], DropdownButtonFormField( key: ValueKey(_supermarket), initialValue: _supermarket, hint: const Text('Select supermarket'), - decoration: const InputDecoration( - labelText: 'Supermarket', - ), + decoration: const InputDecoration(labelText: 'Supermarket'), items: [ for (final store in names) DropdownMenuItem( @@ -177,9 +198,7 @@ class _ReviewScreenState extends State { TextField( enabled: !_saving, textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Store name', - ), + decoration: const InputDecoration(labelText: 'Store name'), onChanged: (value) => _customStore = value, ), ], @@ -198,7 +217,7 @@ class _ReviewScreenState extends State { child: _items.isEmpty ? Center( child: Text( - 'No line items found.\nAdd anything the parser missed.', + 'No line items yet.\nAdd products for this trip.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), @@ -231,11 +250,7 @@ class _ReviewScreenState extends State { }, child: ListTile( title: Text(item.name), - subtitle: item.quantity == null - ? null - : Text( - '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', - ), + subtitle: _itemSubtitle(item), trailing: Text( formatEuro(item.price), style: theme.textTheme.titleMedium, @@ -274,9 +289,7 @@ class _ReviewScreenState extends State { FilledButton( onPressed: _saving ? null : _confirm, child: Text( - _saving - ? 'Saving…' - : 'Save trip · ${formatEuro(total)}', + _saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}', ), ), ], @@ -287,98 +300,14 @@ class _ReviewScreenState extends State { ), ); } -} -class _ItemEditSheet extends StatefulWidget { - const _ItemEditSheet({this.item}); - - final LineItem? item; - - @override - State<_ItemEditSheet> createState() => _ItemEditSheetState(); -} - -class _ItemEditSheetState extends State<_ItemEditSheet> { - late final TextEditingController _nameController; - late final TextEditingController _priceController; - String? _error; - - @override - void initState() { - super.initState(); - final item = widget.item; - _nameController = TextEditingController(text: item?.name ?? ''); - _priceController = TextEditingController( - text: item == null ? '' : item.price.toStringAsFixed(2), - ); - } - - @override - void dispose() { - _nameController.dispose(); - _priceController.dispose(); - super.dispose(); - } - - void _save() { - final name = _nameController.text.trim(); - final price = tryParsePrice(_priceController.text); - if (name.isEmpty || price == null) { - setState(() => _error = 'Enter a product name and a price.'); - return; - } - Navigator.of(context).pop( - LineItem( - id: widget.item?.id ?? 'manual-${DateTime.now().microsecondsSinceEpoch}', - name: name, - price: roundMoney(price), - quantity: widget.item?.quantity, - unitPrice: widget.item?.unitPrice, - ), - ); - } - - @override - Widget build(BuildContext context) { - final bottom = MediaQuery.viewInsetsOf(context).bottom; - - return Padding( - padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - widget.item == null ? 'Add item' : 'Edit item', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - TextField( - controller: _nameController, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration(labelText: 'Product name'), - autofocus: widget.item == null, - ), - const SizedBox(height: 12), - TextField( - controller: _priceController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Price'), - ), - if (_error != null) ...[ - const SizedBox(height: 8), - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - const SizedBox(height: 16), - FilledButton( - onPressed: _save, - child: const Text('Save'), - ), - ], - ), - ); + Widget? _itemSubtitle(LineItem item) { + final parts = [ + if (item.quantity != null) + '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', + if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}', + ]; + if (parts.isEmpty) return null; + return Text(parts.join(' · ')); } } diff --git a/lib/scan_screen.dart b/lib/scan_screen.dart index 05493f3..e18419a 100644 --- a/lib/scan_screen.dart +++ b/lib/scan_screen.dart @@ -8,6 +8,8 @@ import 'package:flutter/services.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'package:image_picker/image_picker.dart'; +import 'crop_receipt_screen.dart'; +import 'models/line_item.dart'; import 'review_screen.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; @@ -17,11 +19,7 @@ import 'utils/supermarkets.dart'; import 'widgets/settings_button.dart'; class ScanScreen extends StatefulWidget { - const ScanScreen({ - super.key, - required this.repository, - this.onTripSaved, - }); + const ScanScreen({super.key, required this.repository, this.onTripSaved}); final ProductRepository repository; final VoidCallback? onTripSaved; @@ -51,7 +49,7 @@ class _ScanScreenState extends State { if (!mounted || response.isEmpty) return; final file = response.file; if (file != null) { - await _runOcr(file.path); + await _cropThenOcr(file.path); } } on UnimplementedError { // Desktop / platforms that do not support lost-data recovery. @@ -59,12 +57,9 @@ class _ScanScreenState extends State { } Future _capture(ImageSource source) async { - final file = await _picker.pickImage( - source: source, - imageQuality: 95, - ); + final file = await _picker.pickImage(source: source, imageQuality: 95); if (file == null || !mounted) return; - await _runOcr(file.path); + await _cropThenOcr(file.path); } Future _scanSampleReceipt() async { @@ -75,7 +70,7 @@ class _ScanScreenState extends State { ); await file.writeAsBytes(data.buffer.asUint8List(), flush: true); if (!mounted) return; - await _runOcr(file.path); + await _cropThenOcr(file.path); } catch (_) { if (!mounted) return; setState(() { @@ -84,6 +79,17 @@ class _ScanScreenState extends State { } } + Future _cropThenOcr(String imagePath) async { + final croppedPath = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => CropReceiptScreen(imagePath: imagePath), + ), + ); + if (croppedPath == null || !mounted) return; + await _runOcr(croppedPath); + } + Future _runOcr(String imagePath) async { setState(() { _busy = true; @@ -127,14 +133,26 @@ class _ScanScreenState extends State { } } + Future _enterManually() async { + await _openReview('', items: const []); + } + String _ocrFailureMessage(String? detail) { const fallback = 'Could not read text from this image.'; if (detail == null || detail.trim().isEmpty) return fallback; return '$fallback\n$detail'; } - Future _openReview(String rawText) async { - final items = ReceiptParser.parse(rawText); + Future _openReview( + String rawText, { + List? items, + String? imagePath, + }) async { + final parsedItems = items; + ReceiptParseResult? parsed; + if (parsedItems == null) { + parsed = ReceiptParser.parseReceipt(rawText); + } if (!mounted) return; final names = SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ?? @@ -142,11 +160,13 @@ class _ScanScreenState extends State { final saved = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ReviewScreen( - items: items, + items: parsedItems ?? parsed!.items, rawText: rawText, repository: widget.repository, - imagePath: _imagePath, + imagePath: imagePath ?? (items == null ? _imagePath : null), supermarketNames: names.toList(), + needsReview: parsed != null && !parsed.validationPassed, + detectedStore: parsed?.store, ), ), ); @@ -182,6 +202,12 @@ class _ScanScreenState extends State { label: const Text('Pick from gallery'), ), const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _busy ? null : _enterManually, + icon: const Icon(Icons.edit_note_outlined), + label: const Text('Enter manually'), + ), + const SizedBox(height: 8), TextButton.icon( onPressed: _busy ? null : _scanSampleReceipt, icon: const Icon(Icons.receipt_long_outlined), @@ -198,13 +224,12 @@ class _ScanScreenState extends State { const SizedBox(height: 16), const LinearProgressIndicator(), const SizedBox(height: 8), - Text( - 'Reading text…', - style: theme.textTheme.bodyMedium, - ), + Text('Reading text…', style: theme.textTheme.bodyMedium), ], const SizedBox(height: 16), - Expanded(child: _ResultPane(text: _extractedText, error: _error)), + Expanded( + child: _ResultPane(text: _extractedText, error: _error), + ), ], ), ), @@ -236,7 +261,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\nConfirmed trips show up in Shop log.', + 'Scan a receipt or enter a trip by hand.\n\nConfirmed trips show up in Shop log.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), diff --git a/lib/services/backup_format.dart b/lib/services/backup_format.dart index 0bab531..6b6d4fe 100644 --- a/lib/services/backup_format.dart +++ b/lib/services/backup_format.dart @@ -3,7 +3,7 @@ library; import 'dart:convert'; -const int kBackupVersion = 1; +const int kBackupVersion = 2; class BackupDocument { const BackupDocument({ @@ -14,6 +14,8 @@ class BackupDocument { required this.products, required this.shops, required this.shopItems, + required this.shoppingLists, + required this.shoppingListItems, required this.images, }); @@ -24,6 +26,8 @@ class BackupDocument { final List> products; final List> shops; final List> shopItems; + final List> shoppingLists; + final List> shoppingListItems; final Map images; Map toJson() => { @@ -35,6 +39,8 @@ class BackupDocument { 'products': products, 'shops': shops, 'shopItems': shopItems, + 'shoppingLists': shoppingLists, + 'shoppingListItems': shoppingListItems, 'images': images, }; @@ -60,6 +66,8 @@ class BackupDocument { products: _maps(decoded['products']), shops: _maps(decoded['shops']), shopItems: _maps(decoded['shopItems']), + shoppingLists: _maps(decoded['shoppingLists']), + shoppingListItems: _maps(decoded['shoppingListItems']), images: _strings(decoded['images']), ); } diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index 683827f..496b22b 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -57,6 +57,8 @@ class BackupService { products: rewrittenProducts, shops: rewrittenShops, shopItems: await repository.dumpTable('shop_items'), + shoppingLists: await repository.dumpTable('shopping_lists'), + shoppingListItems: await repository.dumpTable('shopping_list_items'), images: images, ); } @@ -114,7 +116,10 @@ class BackupService { 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['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) { @@ -141,14 +146,13 @@ class BackupService { products: products, shops: shops, shopItems: document.shopItems, + shoppingLists: document.shoppingLists, + shoppingListItems: document.shoppingListItems, darkMode: document.darkMode, ); } - Future _restoreImage( - Object? key, - Map images, - ) async { + Future _restoreImage(Object? key, Map images) async { if (key is! String || key.isEmpty) return null; final encoded = images[key]; if (encoded == null) return null; diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index 1bca3d2..8466871 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -11,6 +11,7 @@ import '../models/line_item.dart'; import '../models/product.dart'; import '../models/purchase.dart'; import '../models/shop.dart'; +import '../models/shopping_list.dart'; import '../models/supermarket.dart'; import '../utils/money.dart'; import '../utils/supermarkets.dart'; @@ -27,15 +28,20 @@ class ProductRepository { } Future _open() async { - final path = databasePath ?? + final path = + databasePath ?? p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); return openDatabase( path, - version: 4, + version: 5, + onConfigure: (db) async { + await db.execute('PRAGMA foreign_keys = ON'); + }, onCreate: (db, version) async { await _createProducts(db); await _createShopTables(db); await _createSettingsTables(db); + await _createShoppingListTables(db); await _seedSupermarkets(db); }, onUpgrade: (db, oldVersion, newVersion) async { @@ -55,6 +61,9 @@ class ProductRepository { if (oldVersion < 4) { await db.execute('ALTER TABLE products ADD COLUMN category TEXT'); } + if (oldVersion < 5) { + await _createShoppingListTables(db); + } }, ); } @@ -133,6 +142,29 @@ class ProductRepository { '''); } + Future _createShoppingListTables(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS shopping_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS shopping_list_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL, + product_id INTEGER, + name TEXT NOT NULL, + checked INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) + ) + '''); + } + static String normalizeName(String name) => name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); @@ -315,13 +347,11 @@ class ProductRepository { Future> getUsedCategories() async { final db = await _database; - final rows = await db.rawQuery( - ''' + 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, @@ -455,6 +485,238 @@ class ProductRepository { return rows.map(ShopItem.fromMap).toList(); } + /// Removes a trip and its line items. Catalog products are left unchanged. + Future deleteShop(int id) async { + final shop = await getShop(id); + final db = await _database; + await db.transaction((txn) async { + await txn.delete('shop_items', where: 'shop_id = ?', whereArgs: [id]); + await txn.delete('shops', where: 'id = ?', whereArgs: [id]); + }); + final path = shop?.receiptImagePath; + if (path == null || path.isEmpty) return; + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + + /// Removes one trip line. Catalog products are left unchanged. + Future deleteShopItem(int id) async { + final db = await _database; + final rows = await db.query( + 'shop_items', + columns: ['shop_id'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return; + final shopId = rows.first['shop_id']! as int; + await db.delete('shop_items', where: 'id = ?', whereArgs: [id]); + await _recalculateShopTotal(shopId); + } + + Future updateShopItem({ + required int id, + String? name, + double? price, + int? quantity, + double? unitPrice, + }) async { + final db = await _database; + final rows = await db.query( + 'shop_items', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return; + final shopId = rows.first['shop_id']! as int; + final values = {}; + if (name != null) values['name'] = name.trim(); + if (price != null) values['price'] = price; + if (quantity != null) values['quantity'] = quantity; + if (unitPrice != null) values['unit_price'] = unitPrice; + if (values.isEmpty) return; + await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]); + await _recalculateShopTotal(shopId); + } + + Future _recalculateShopTotal(int shopId) async { + final db = await _database; + final rows = await db.rawQuery( + 'SELECT COALESCE(SUM(price), 0) AS total FROM shop_items WHERE shop_id = ?', + [shopId], + ); + final total = roundMoney((rows.first['total'] as num).toDouble()); + await db.update( + 'shops', + {'total': total}, + where: 'id = ?', + whereArgs: [shopId], + ); + } + + Future> getShoppingLists() async { + final db = await _database; + final rows = await db.rawQuery(''' + SELECT + shopping_lists.id, + shopping_lists.name, + shopping_lists.updated_at, + COUNT(shopping_list_items.id) AS item_count, + COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count + FROM shopping_lists + LEFT JOIN shopping_list_items + ON shopping_list_items.list_id = shopping_lists.id + GROUP BY shopping_lists.id + ORDER BY shopping_lists.updated_at DESC, shopping_lists.id DESC + '''); + return rows.map(ShoppingList.fromMap).toList(); + } + + Future getShoppingList(int id) async { + final db = await _database; + final rows = await db.rawQuery( + ''' + SELECT + shopping_lists.id, + shopping_lists.name, + shopping_lists.updated_at, + COUNT(shopping_list_items.id) AS item_count, + COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count + FROM shopping_lists + LEFT JOIN shopping_list_items + ON shopping_list_items.list_id = shopping_lists.id + WHERE shopping_lists.id = ? + GROUP BY shopping_lists.id + ''', + [id], + ); + if (rows.isEmpty) return null; + return ShoppingList.fromMap(rows.first); + } + + Future createShoppingList(String name) async { + final db = await _database; + final now = DateTime.now().toIso8601String(); + return db.insert('shopping_lists', { + 'name': name.trim(), + 'created_at': now, + 'updated_at': now, + }); + } + + Future renameShoppingList(int id, String name) async { + final db = await _database; + await db.update( + 'shopping_lists', + {'name': name.trim(), 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future deleteShoppingList(int id) async { + final db = await _database; + await db.transaction((txn) async { + await txn.delete( + 'shopping_list_items', + where: 'list_id = ?', + whereArgs: [id], + ); + await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]); + }); + } + + Future> getShoppingListItems(int listId) async { + final db = await _database; + final rows = await db.query( + 'shopping_list_items', + where: 'list_id = ?', + whereArgs: [listId], + orderBy: 'checked ASC, sort_order ASC, id ASC', + ); + return rows.map(ShoppingListItem.fromMap).toList(); + } + + Future addShoppingListItem({ + required int listId, + required String name, + int? productId, + }) async { + final db = await _database; + final max = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?', + [listId], + ), + ); + final id = await db.insert('shopping_list_items', { + 'list_id': listId, + 'product_id': productId, + 'name': name.trim(), + 'checked': 0, + 'sort_order': (max ?? -1) + 1, + }); + await _touchShoppingList(listId); + return id; + } + + Future updateShoppingListItem({ + required int id, + String? name, + bool? checked, + int? productId, + }) async { + final db = await _database; + final rows = await db.query( + 'shopping_list_items', + columns: ['list_id'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return; + final values = {}; + if (name != null) values['name'] = name.trim(); + if (checked != null) values['checked'] = checked ? 1 : 0; + if (productId != null) values['product_id'] = productId; + if (values.isEmpty) return; + await db.update( + 'shopping_list_items', + values, + where: 'id = ?', + whereArgs: [id], + ); + await _touchShoppingList(rows.first['list_id']! as int); + } + + Future deleteShoppingListItem(int id) async { + final db = await _database; + final rows = await db.query( + 'shopping_list_items', + columns: ['list_id'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return; + await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]); + await _touchShoppingList(rows.first['list_id']! as int); + } + + Future _touchShoppingList(int id) async { + final db = await _database; + await db.update( + 'shopping_lists', + {'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + } + Future getDarkMode() async { final db = await _database; final rows = await db.query( @@ -469,16 +731,18 @@ class ProductRepository { Future setDarkMode(bool enabled) async { final db = await _database; - await db.insert( - 'app_settings', - {'key': 'dark_mode', 'value': enabled ? '1' : '0'}, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + 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'); + final rows = await db.query( + 'supermarkets', + orderBy: 'sort_order ASC, id ASC', + ); if (rows.isEmpty) { await _seedSupermarkets(db); return getSupermarkets(); @@ -579,9 +843,13 @@ class ProductRepository { required List> shops, required List> shopItems, required bool darkMode, + List> shoppingLists = const [], + List> shoppingListItems = const [], }) async { final db = await _database; await db.transaction((txn) async { + await txn.delete('shopping_list_items'); + await txn.delete('shopping_lists'); await txn.delete('shop_items'); await txn.delete('shops'); await txn.delete('products'); @@ -600,6 +868,15 @@ class ProductRepository { for (final row in shopItems) { await txn.insert('shop_items', _sqlRow(row, _shopItemColumns)); } + for (final row in shoppingLists) { + await txn.insert('shopping_lists', _sqlRow(row, _shoppingListColumns)); + } + for (final row in shoppingListItems) { + await txn.insert( + 'shopping_list_items', + _sqlRow(row, _shoppingListItemColumns), + ); + } await txn.insert('app_settings', { 'key': 'dark_mode', 'value': darkMode ? '1' : '0', @@ -609,15 +886,12 @@ class ProductRepository { await _resetSequence(txn, 'products'); await _resetSequence(txn, 'shops'); await _resetSequence(txn, 'shop_items'); + await _resetSequence(txn, 'shopping_lists'); + await _resetSequence(txn, 'shopping_list_items'); }); } - static const _supermarketColumns = { - 'id', - 'name', - 'color', - 'sort_order', - }; + static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'}; static const _productColumns = { 'id', 'name', @@ -649,6 +923,20 @@ class ProductRepository { 'quantity', 'unit_price', }; + static const _shoppingListColumns = { + 'id', + 'name', + 'created_at', + 'updated_at', + }; + static const _shoppingListItemColumns = { + 'id', + 'list_id', + 'product_id', + 'name', + 'checked', + 'sort_order', + }; Map _sqlRow( Map source, diff --git a/lib/services/receipt_parse_config.dart b/lib/services/receipt_parse_config.dart new file mode 100644 index 0000000..10e9a49 --- /dev/null +++ b/lib/services/receipt_parse_config.dart @@ -0,0 +1,179 @@ +/// Keyword and regex config loaded from receipt-parsing-keywords.json. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class ReceiptParseConfig { + ReceiptParseConfig({ + required this.knownStores, + required this.columnHeaderKeywords, + required this.separatorPattern, + required this.quantityStandalone, + required this.quantityInline, + required this.discountKeywords, + required this.negativeAmount, + required this.totalDiscountKeywords, + required this.subtotalKeywords, + required this.grandTotalKeywords, + required this.taxInclusiveTotalKeywords, + required this.paymentKeywords, + required this.taxKeywords, + required this.categoryRow, + required this.trailingCategoryLetter, + required this.noiseKeywords, + required this.itemCountKeywords, + }); + + final List knownStores; + final List columnHeaderKeywords; + final RegExp separatorPattern; + final RegExp quantityStandalone; + final RegExp quantityInline; + final List discountKeywords; + final RegExp negativeAmount; + final List totalDiscountKeywords; + final List subtotalKeywords; + final List grandTotalKeywords; + final List taxInclusiveTotalKeywords; + final List paymentKeywords; + final List taxKeywords; + final RegExp categoryRow; + final RegExp trailingCategoryLetter; + final List noiseKeywords; + final List itemCountKeywords; + + factory ReceiptParseConfig.fromJson(Map json) { + List list(String section, [String key = 'list']) { + final block = json[section]; + if (block is! Map) return const []; + final value = block[key]; + if (value is! List) return const []; + return [ + for (final item in value) + if (item is String) item, + ]; + } + + RegExp rx(String section, String key, {bool caseInsensitive = true}) { + final block = json[section]; + final value = block is Map ? block[key] : null; + final source = value is String ? value : '^\$'; + return RegExp(source, caseSensitive: !caseInsensitive); + } + + return ReceiptParseConfig( + knownStores: list('known_stores'), + columnHeaderKeywords: list('column_header_keywords'), + separatorPattern: rx('column_header_keywords', 'separator_pattern'), + quantityStandalone: rx( + 'quantity_modifier_pattern', + 'regex', + caseInsensitive: true, + ), + quantityInline: rx( + 'quantity_modifier_pattern', + 'inline_regex', + caseInsensitive: true, + ), + discountKeywords: list('discount_keywords'), + negativeAmount: rx('discount_keywords', 'negative_amount_pattern'), + totalDiscountKeywords: list( + 'discount_keywords', + 'receipt_level_total_discount_keywords', + ), + subtotalKeywords: list('total_keywords', 'subtotal_keywords'), + grandTotalKeywords: list('total_keywords', 'grand_total_keywords'), + taxInclusiveTotalKeywords: list( + 'total_keywords', + 'tax_inclusive_total_keywords', + ), + paymentKeywords: list('payment_keywords'), + taxKeywords: list('tax_breakdown_keywords'), + categoryRow: rx('tax_breakdown_keywords', 'category_row_pattern'), + trailingCategoryLetter: rx( + 'tax_breakdown_keywords', + 'trailing_category_letter_pattern', + ), + noiseKeywords: list('noise_footer_keywords'), + itemCountKeywords: list('line_item_count_keywords'), + ); + } + + static const assetPath = 'receipt-parsing-keywords.json'; + + /// True if [line] matches a config keyword. + /// + /// Short tokens (3 characters or fewer) use a word boundary so `PIN` does + /// not match `SPINAZIE`. Longer tokens use a case-insensitive substring, + /// and a letter-only compact form so OCR splits like `Betaal d:` still + /// match `BETAALD`. + bool matchesKeyword(String line, String keyword) { + final upper = line.toUpperCase(); + final key = keyword.toUpperCase(); + if (key.length <= 3) { + return RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper); + } + if (upper.contains(key)) return true; + final compactLine = upper.replaceAll(RegExp(r'[^A-Z0-9]'), ''); + final compactKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); + return compactKey.length >= 4 && compactLine.contains(compactKey); + } + + bool isStandaloneKeywordLine(String line, List keywords) { + final compact = line.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + for (final keyword in keywords) { + final key = keyword.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + if (key.isEmpty) continue; + if (compact == key) return true; + if (compact.startsWith(key) && compact.length <= key.length + 3) { + return true; + } + } + return false; + } + + bool matchesAny(String line, List keywords) { + for (final keyword in keywords) { + if (matchesKeyword(line, keyword)) return true; + } + return false; + } +} + +ReceiptParseConfig? _loadedConfig; + +ReceiptParseConfig get receiptParseConfig { + if (_loadedConfig != null) return _loadedConfig!; + final file = File(ReceiptParseConfig.assetPath); + if (file.existsSync()) { + _loadedConfig = ReceiptParseConfig.fromJson( + jsonDecode(file.readAsStringSync()) as Map, + ); + return _loadedConfig!; + } + throw StateError( + 'Receipt keyword config was not loaded. Call ReceiptParser.loadConfig().', + ); +} + +void setReceiptParseConfig(ReceiptParseConfig config) { + _loadedConfig = config; +} + +Future loadReceiptParseConfig() async { + try { + final raw = await rootBundle.loadString(ReceiptParseConfig.assetPath); + _loadedConfig = ReceiptParseConfig.fromJson( + jsonDecode(raw) as Map, + ); + } catch (_) { + final file = File(ReceiptParseConfig.assetPath); + if (!file.existsSync()) rethrow; + _loadedConfig = ReceiptParseConfig.fromJson( + jsonDecode(file.readAsStringSync()) as Map, + ); + } +} diff --git a/lib/services/receipt_parser.dart b/lib/services/receipt_parser.dart index 5fe9015..b0ab6cb 100644 --- a/lib/services/receipt_parser.dart +++ b/lib/services/receipt_parser.dart @@ -1,57 +1,31 @@ -/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter. +/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json. library; import '../models/line_item.dart'; import '../utils/money.dart'; +import 'receipt_parse_config.dart'; -/// Headers, payments, and totals — never treated as products. -const Set kHeaderKeywords = { - 'TOTAL', - 'SUBTOTAL', - 'TOTAAL', - 'TAX', - 'VAT', - 'BTW', - 'CASH', - 'CARD', - 'CHANGE', - 'VISA', - 'MASTERCARD', - 'MAESTRO', - 'PIN', - 'DEBIT', - 'CREDIT', - 'OMSCHRIJVING', - 'BEDRAG', - 'AMOUNT', - 'DESCRIPTION', - 'THANK', - 'BEDANKT', - 'WISSELGELD', - 'BETAALD', - 'BETAAL', - 'CONTANT', - 'KASSA', - 'RECEIPT', - 'WWW', - 'HTTP', -}; +export 'receipt_parse_config.dart' show loadReceiptParseConfig; -/// Discount / promo rows. Paired with a price, then dropped. -const Set kDropKeywords = { - 'DISCOUNT', - 'KORTING', - 'ACTIE', -}; +/// Result of a full receipt parse, including totals used for review. +class ReceiptParseResult { + const ReceiptParseResult({ + required this.items, + this.store, + this.receiptTotal, + this.totalDiscount = 0, + this.validationPassed = true, + }); -final _separatorPattern = RegExp(r'^[=\-_.:\s]+$'); -final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b'); -final _phonePattern = RegExp(r'^\d{8,}$'); -final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}$"); -final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false); + final List items; + final String? store; + final double? receiptTotal; + final double totalDiscount; + final bool validationPassed; +} -class _QtyLine { - const _QtyLine({required this.count, required this.unitPrice, this.total}); +class _Qty { + const _Qty({required this.count, required this.unitPrice, this.total}); final int count; final double unitPrice; @@ -60,6 +34,59 @@ class _QtyLine { double get lineTotal => total ?? roundMoney(count * unitPrice); } +class _Pending { + _Pending({ + required this.name, + this.unitPrice, + this.quantity = 1, + this.lineTotal, + }); + + String name; + double? unitPrice; + int quantity; + double? lineTotal; + double discount = 0; +} + +enum _Kind { + skip, + item, + nameOnly, + quantity, + discount, + grandTotal, + totalDiscount, + priceOnly, +} + +class _Line { + const _Line({ + required this.kind, + required this.raw, + this.name, + this.amount, + this.qty, + }); + + final _Kind kind; + final String raw; + final String? name; + final double? amount; + final _Qty? qty; +} + +final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b'); +final _phonePattern = RegExp(r'^\d{8,}$'); +final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$"); +final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false); + +/// Jumbo-style OCR often adds a line total after `N X unit`. +final _qtyWithTotal = RegExp( + r'^\s*(\d+)\s*[xX×]\s*(-?\d+[,.]\d{2})(?:\s+(?:PER\s+STUK|PER\s+KG|ST\.?|STUKS?))?(?:\s+(-?\d+[,.]\d{2}))?\s*$', + caseSensitive: false, +); + class ReceiptParser { ReceiptParser._(); @@ -67,74 +94,198 @@ class ReceiptParser { static String _id() => 'item-${++_nextId}'; - /// Parses [rawText] into product rows. - /// - /// Prefers same-line `name … price` (with a following `2 x 1.50` line when - /// the name has no price). If OCR split the receipt into a name column and a - /// price column, falls back to pairing those columns. - static List parse(String rawText) { + static Future loadConfig() => loadReceiptParseConfig(); + + static List parse(String rawText) => parseReceipt(rawText).items; + + static ReceiptParseResult parseReceipt(String rawText) { + final config = receiptParseConfig; final lines = rawText .split('\n') - .map((line) => line.trim()) + .map(_preprocess) .where((line) => line.isNotEmpty) .toList(); - final inline = _parseInline(lines); - final columns = _parseColumns(lines); - final chosen = columns.length > inline.length ? columns : inline; - return chosen.where(_keepItem).toList(); + if (_looksLikeColumnOcr(lines, config)) { + return _parseColumns(lines, config, rawText); + } + return _parseSequential(lines, config, rawText); } - static List _parseInline(List lines) { - final items = []; + static String _preprocess(String line) => + line.trim().replaceAll(RegExp(r'\s+'), ' '); - for (var i = 0; i < lines.length; i++) { - final line = lines[i]; - if (_shouldSkipLine(line) || isPriceOnly(line)) continue; + static String _stripTax(String line, ReceiptParseConfig config) { + return line.replaceFirst(config.trailingCategoryLetter, '').trim(); + } - final qty = _parseQty(line); - if (qty != null) continue; - - final trailing = trailingPricePattern.firstMatch(line); - if (trailing != null) { - final name = trailing.group(1)!.trim(); - final price = tryParsePrice(trailing.group(2)!); - if (name.isEmpty || price == null || _shouldSkipLine(name)) continue; - items.add(LineItem(id: _id(), name: name, price: price)); - continue; + static bool _looksLikeColumnOcr( + List lines, + ReceiptParseConfig config, + ) { + var run = 0; + var maxRun = 0; + var prices = 0; + var names = 0; + for (final line in lines) { + final stripped = _stripTax(line, config); + if (isPriceOnly(stripped)) { + prices++; + run++; + if (run > maxRun) maxRun = run; + } else { + run = 0; + if (_parseQty(stripped, config) == null && + !_isIgnorable(stripped, config)) { + names++; + } } + } + return maxRun >= 4 && prices >= 4 && names >= 4; + } - if (i + 1 >= lines.length) continue; - final nextQty = _parseQty(lines[i + 1]); - if (nextQty == null) continue; - items.add( - LineItem( - id: _id(), - name: line, - price: nextQty.lineTotal, - quantity: nextQty.count, - unitPrice: nextQty.unitPrice, - ), - ); - i++; + static ReceiptParseResult _parseSequential( + List lines, + ReceiptParseConfig config, + String rawText, + ) { + final items = []; + _Pending? pending; + var receiptTotal = _readAmountAfterKeyword( + lines, + config, + config.grandTotalKeywords, + skipIf: (line) => config.matchesAny(line, [ + ...config.totalDiscountKeywords, + ...config.taxInclusiveTotalKeywords, + ...config.subtotalKeywords, + ]), + ); + var totalDiscount = 0.0; + var stop = false; + + void flush() { + final current = pending; + pending = null; + if (current == null) return; + final total = current.lineTotal ?? current.unitPrice; + if (total == null) return; + items.add(_toItem(current, total)); } - return items; + for (var i = 0; i < lines.length && !stop; i++) { + final classified = _classify(lines[i], config); + switch (classified.kind) { + case _Kind.skip: + continue; + case _Kind.item: + flush(); + pending = _Pending( + name: classified.name!, + unitPrice: classified.amount, + quantity: classified.qty?.count ?? 1, + lineTotal: classified.qty == null + ? classified.amount + : classified.qty!.lineTotal, + ); + if (classified.qty != null) { + pending!.unitPrice = classified.qty!.unitPrice; + } + case _Kind.nameOnly: + flush(); + pending = _Pending(name: classified.name ?? classified.raw); + case _Kind.quantity: + final qty = classified.qty!; + if (pending != null) { + pending! + ..quantity = qty.count + ..unitPrice = qty.unitPrice + ..lineTotal = qty.lineTotal; + } + case _Kind.discount: + var amount = classified.amount; + if (amount == null && i + 1 < lines.length) { + amount = tryParsePrice(_stripTax(lines[i + 1], config)); + if (amount != null) i++; + } + if (amount == null) continue; + if (amount > 0) amount = -amount; + if (pending != null) { + pending!.discount += amount; + pending!.lineTotal = roundMoney( + (pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount, + ); + } else { + totalDiscount += amount; + } + case _Kind.totalDiscount: + var amount = classified.amount; + if (amount == null && i + 1 < lines.length) { + amount = tryParsePrice(_stripTax(lines[i + 1], config)); + if (amount != null) i++; + } + if (amount != null) { + totalDiscount += amount > 0 ? -amount : amount; + } + case _Kind.grandTotal: + flush(); + if (classified.amount != null) { + receiptTotal = classified.amount; + stop = true; + } + case _Kind.priceOnly: + final amount = classified.amount!; + if (amount < 0 && pending != null) { + pending!.discount += amount; + pending!.lineTotal = roundMoney( + (pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount, + ); + } + } + } + flush(); + + return _result( + items: items, + rawText: rawText, + receiptTotal: receiptTotal, + totalDiscount: totalDiscount, + ); } - static List _parseColumns(List lines) { + static ReceiptParseResult _parseColumns( + List lines, + ReceiptParseConfig config, + String rawText, + ) { final names = <({String name, int? quantity, double? unitPrice})>[]; final prices = []; + final discountSlots = []; + final discounts = []; - for (final line in lines) { + for (final raw in lines) { + final line = _stripTax(raw, config); if (isPriceOnly(line)) { final price = tryParsePrice(line); - if (price != null) prices.add(price); + if (price == null) continue; + if (price < 0) { + discounts.add(price); + } else { + prices.add(price); + } + continue; + } + if (_isIgnorable(line, config)) continue; + if (config.matchesAny(line, [ + ...config.grandTotalKeywords, + ...config.subtotalKeywords, + ...config.taxInclusiveTotalKeywords, + ...config.totalDiscountKeywords, + ])) { continue; } - if (_shouldSkipLine(line)) continue; - final qty = _parseQty(line); + final qty = _parseQty(line, config); if (qty != null) { if (names.isNotEmpty) { final last = names.removeLast(); @@ -147,11 +298,16 @@ class ReceiptParser { continue; } + if (config.matchesAny(line, config.discountKeywords)) { + if (names.isNotEmpty) discountSlots.add(names.length - 1); + continue; + } + names.add((name: line, quantity: null, unitPrice: null)); } final count = names.length < prices.length ? names.length : prices.length; - return [ + final items = [ for (var i = 0; i < count; i++) LineItem( id: _id(), @@ -161,44 +317,274 @@ class ReceiptParser { unitPrice: names[i].unitPrice, ), ]; + + for (var i = 0; i < discountSlots.length && i < discounts.length; i++) { + final index = discountSlots[i]; + if (index < 0 || index >= items.length) continue; + final item = items[index]; + final discount = discounts[i]; + items[index] = item.copyWith( + price: roundMoney(item.price + discount), + discount: roundMoney(item.discount + discount), + ); + } + + final receiptTotal = _readAmountAfterKeyword( + lines, + config, + config.grandTotalKeywords, + skipIf: (line) => config.matchesAny(line, [ + ...config.totalDiscountKeywords, + ...config.taxInclusiveTotalKeywords, + ...config.subtotalKeywords, + ]), + ); + var totalDiscount = discounts.fold(0, (sum, value) => sum + value); + totalDiscount += + _readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ?? + 0; + + return _result( + items: items, + rawText: rawText, + receiptTotal: receiptTotal, + totalDiscount: totalDiscount, + ); } - static bool _keepItem(LineItem item) { - if (item.price < 0) return false; - if (item.name.trim().isEmpty) return false; - return !_containsKeyword(item.name, kDropKeywords); + static LineItem _toItem(_Pending pending, double total) { + return LineItem( + id: _id(), + name: pending.name, + price: roundMoney(total), + quantity: pending.quantity == 1 ? null : pending.quantity, + unitPrice: pending.quantity == 1 ? null : pending.unitPrice, + discount: pending.discount == 0 ? 0 : roundMoney(pending.discount), + ); } - static bool _shouldSkipLine(String line) { - if (_separatorPattern.hasMatch(line)) return true; + static ReceiptParseResult _result({ + required List items, + required String rawText, + double? receiptTotal, + double totalDiscount = 0, + }) { + final kept = items + .where((item) => item.name.trim().isNotEmpty && item.price > 0) + .toList(); + final sum = roundMoney( + kept.fold(0, (total, item) => total + item.price), + ); + final passed = + receiptTotal == null || + (sum - receiptTotal).abs() <= 0.01 || + (sum + totalDiscount - receiptTotal).abs() <= 0.01; + return ReceiptParseResult( + items: kept, + store: _detectStore(rawText), + receiptTotal: receiptTotal, + totalDiscount: totalDiscount, + validationPassed: passed, + ); + } + + static String? _detectStore(String rawText) { + final config = receiptParseConfig; + final header = rawText + .split('\n') + .map(_preprocess) + .where((line) => line.isNotEmpty) + .take(8) + .join('\n') + .toUpperCase(); + final stores = [...config.knownStores] + ..sort((a, b) => b.length.compareTo(a.length)); + for (final store in stores) { + if (RegExp('\\b${RegExp.escape(store)}\\b').hasMatch(header)) { + return _canonicalStore(store); + } + } + return null; + } + + static String _canonicalStore(String raw) { + const aliases = { + 'AH': 'Albert Heijn', + 'ALBERT HEIJN': 'Albert Heijn', + 'JUMBO': 'Jumbo', + 'LIDL': 'Lidl', + 'KRUIDVAT': 'Kruidvat', + 'ALDI': 'Aldi', + 'PLUS': 'Plus', + 'DIRK': 'Dirk', + 'COOP': 'Coop', + 'SPAR': 'SPAR', + 'VOMAR': 'Vomar', + 'EKOPLAZA': 'Ekoplaza', + 'HOOGVLIET': 'Hoogvliet', + 'ACTION': 'Action', + 'ETOS': 'Etos', + 'WORLD TOKO': 'World Toko', + }; + return aliases[raw.toUpperCase()] ?? raw; + } + + static double? _readAmountAfterKeyword( + List lines, + ReceiptParseConfig config, + List keywords, { + bool Function(String line)? skipIf, + }) { + for (var i = 0; i < lines.length; i++) { + final line = _stripTax(lines[i], config); + if (!config.matchesAny(line, keywords)) continue; + if (skipIf != null && skipIf(line)) continue; + final trailing = _trailingPrice(line, config); + if (trailing != null) return trailing; + if (i + 1 < lines.length) { + final next = tryParsePrice(_stripTax(lines[i + 1], config)); + if (next != null) return next; + } + } + return null; + } + + static _Line _classify(String raw, ReceiptParseConfig config) { + final line = _stripTax(raw, config); + if (_isIgnorable(line, config)) { + return _Line(kind: _Kind.skip, raw: line); + } + if (config.matchesAny(line, config.totalDiscountKeywords)) { + return _Line( + kind: _Kind.totalDiscount, + raw: line, + amount: _trailingPrice(line, config), + ); + } + if (config.matchesAny(line, config.taxInclusiveTotalKeywords) || + config.matchesAny(line, config.subtotalKeywords)) { + return _Line(kind: _Kind.skip, raw: line); + } + if (config.matchesAny(line, config.grandTotalKeywords)) { + return _Line( + kind: _Kind.grandTotal, + raw: line, + amount: _trailingPrice(line, config), + ); + } + + final qty = _parseQty(line, config); + if (qty != null) { + return _Line(kind: _Kind.quantity, raw: line, qty: qty); + } + + final isDiscount = + config.matchesAny(line, config.discountKeywords) || + config.negativeAmount.hasMatch(line); + if (isDiscount && + (config.matchesAny(line, config.discountKeywords) || + isPriceOnly(line))) { + return _Line( + kind: _Kind.discount, + raw: line, + name: line, + amount: _trailingPrice(line, config) ?? tryParsePrice(line), + ); + } + + if (isPriceOnly(line)) { + return _Line( + kind: _Kind.priceOnly, + raw: line, + amount: tryParsePrice(line), + ); + } + + final inline = config.quantityInline.firstMatch(line); + final trailing = _trailingPrice(line, config); + if (trailing != null) { + var name = line.replaceFirst(RegExp(r'\s+-?\d+[.,]\d{2}\s*$'), '').trim(); + _Qty? inlineQty; + if (inline != null) { + name = line.substring(0, inline.start).trim(); + final count = int.tryParse(inline.group(1)!); + final unit = tryParsePrice(inline.group(2)!); + if (count != null && unit != null) { + inlineQty = _Qty(count: count, unitPrice: unit, total: trailing); + } + } + if (name.isEmpty || _isIgnorable(name, config)) { + return _Line(kind: _Kind.skip, raw: line); + } + return _Line( + kind: _Kind.item, + raw: line, + name: name, + amount: trailing, + qty: inlineQty, + ); + } + + if (inline != null) { + final name = line.substring(0, inline.start).trim(); + final count = int.tryParse(inline.group(1)!); + final unit = tryParsePrice(inline.group(2)!); + if (name.isNotEmpty && count != null && unit != null) { + return _Line( + kind: _Kind.item, + raw: line, + name: name, + amount: roundMoney(count * unit), + qty: _Qty(count: count, unitPrice: unit), + ); + } + } + + return _Line(kind: _Kind.nameOnly, raw: line, name: line); + } + + static bool _isIgnorable(String line, ReceiptParseConfig config) { + if (config.separatorPattern.hasMatch(line)) return true; if (_urlPattern.hasMatch(line)) return true; if (_postalPattern.hasMatch(line)) return true; if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) { return true; } if (_streetPattern.hasMatch(line)) return true; - if (_containsKeyword(line, kHeaderKeywords)) return true; - return false; - } - - static bool _containsKeyword(String line, Set keywords) { - final upper = line.toUpperCase(); - for (final keyword in keywords) { - if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) { - return true; - } + if (config.categoryRow.hasMatch(line)) return true; + if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) { + return true; } + if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) { + return true; + } + if (config.matchesAny(line, config.taxKeywords)) return true; + if (config.matchesAny(line, config.paymentKeywords)) return true; + if (config.matchesAny(line, config.noiseKeywords)) return true; return false; } - static _QtyLine? _parseQty(String line) { - final match = quantityPattern.firstMatch(line.trim()); + static double? _trailingPrice(String line, ReceiptParseConfig config) { + final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line); + if (match == null) return null; + return tryParsePrice(match.group(1)!); + } + + static _Qty? _parseQty(String line, ReceiptParseConfig config) { + final match = + _qtyWithTotal.firstMatch(line) ?? + config.quantityStandalone.firstMatch(line); if (match == null) return null; final count = int.tryParse(match.group(1)!); final unit = tryParsePrice(match.group(2)!); if (count == null || count < 1 || unit == null) return null; - final totalRaw = match.group(3); - final total = totalRaw == null ? null : tryParsePrice(totalRaw); - return _QtyLine(count: count, unitPrice: unit, total: total); + double? total; + if (match.groupCount >= 3) { + final raw = match.group(3); + if (raw != null && RegExp(r'^-?\d+[.,]\d{2}$').hasMatch(raw)) { + total = tryParsePrice(raw); + } + } + return _Qty(count: count, unitPrice: unit, total: total); } } diff --git a/lib/shop_detail_screen.dart b/lib/shop_detail_screen.dart index 73677b8..c622bce 100644 --- a/lib/shop_detail_screen.dart +++ b/lib/shop_detail_screen.dart @@ -5,11 +5,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'models/line_item.dart'; import 'models/shop.dart'; import 'product_detail_screen.dart'; import 'services/product_repository.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; +import 'widgets/confirm_dialog.dart'; +import 'widgets/item_edit_sheet.dart'; class ShopDetailScreen extends StatefulWidget { const ShopDetailScreen({ @@ -27,6 +30,7 @@ class ShopDetailScreen extends StatefulWidget { class _ShopDetailScreenState extends State { late Future<({Shop shop, List items})> _future; + bool _editing = false; @override void initState() { @@ -43,6 +47,10 @@ class _ShopDetailScreenState extends State { return (shop: shop, items: items); } + void _reload() { + setState(() => _future = _load()); + } + Future _openReceipt(File file) async { await showDialog( context: context, @@ -68,12 +76,66 @@ class _ShopDetailScreenState extends State { ); } + Future _editItem(ShopItem item) async { + final result = await showItemEditSheet( + context, + item: LineItem( + id: '${item.id}', + name: item.name, + price: item.price, + quantity: item.quantity, + unitPrice: item.unitPrice, + ), + ); + if (result == null || !mounted) return; + await widget.repository.updateShopItem( + id: item.id, + name: result.name, + price: result.price, + quantity: result.quantity, + unitPrice: result.unitPrice, + ); + if (mounted) _reload(); + } + + Future _removeItem(ShopItem item) async { + final confirmed = await confirmAction( + context, + title: 'Remove item?', + message: + '"${item.name}" will be removed from this trip. The product stays in your catalog.', + confirmLabel: 'Remove', + ); + if (!confirmed || !mounted) return; + await widget.repository.deleteShopItem(item.id); + if (mounted) _reload(); + } + + Widget? _itemSubtitle(ShopItem item) { + final parts = [ + if (item.quantity != null) + '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', + if (_editing) formatEuro(item.price), + ]; + if (parts.isEmpty) return null; + return Text(parts.join(' · ')); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: const Text('Shopping trip')), + appBar: AppBar( + title: const Text('Shopping trip'), + actions: [ + IconButton( + tooltip: _editing ? 'Done' : 'Edit', + onPressed: () => setState(() => _editing = !_editing), + icon: Icon(_editing ? Icons.check : Icons.edit_outlined), + ), + ], + ), body: FutureBuilder( future: _future, builder: (context, snapshot) { @@ -92,10 +154,8 @@ class _ShopDetailScreenState extends State { 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(); + final receiptFile = receipt == null ? null : File(receipt); + final hasReceipt = receiptFile != null && receiptFile.existsSync(); return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), @@ -125,20 +185,39 @@ class _ShopDetailScreenState extends State { const SizedBox(height: 24), Text('Products', style: theme.textTheme.titleMedium), const SizedBox(height: 8), + if (items.isEmpty) + Text( + 'No items on this trip.', + style: theme.textTheme.bodyLarge, + ), for (final item in items) ListTile( contentPadding: EdgeInsets.zero, title: Text(item.name), - subtitle: item.quantity == null - ? null + subtitle: _itemSubtitle(item), + trailing: _editing + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Edit', + visualDensity: VisualDensity.compact, + onPressed: () => _editItem(item), + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Remove', + visualDensity: VisualDensity.compact, + onPressed: () => _removeItem(item), + icon: const Icon(Icons.delete_outline), + ), + ], + ) : Text( - '${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}', + formatEuro(item.price), + style: theme.textTheme.titleMedium, ), - trailing: Text( - formatEuro(item.price), - style: theme.textTheme.titleMedium, - ), - onTap: () => _openProduct(item.productId), + onTap: _editing ? null : () => _openProduct(item.productId), ), const Divider(), ListTile( diff --git a/lib/shop_log_screen.dart b/lib/shop_log_screen.dart index e22c96a..c0f2beb 100644 --- a/lib/shop_log_screen.dart +++ b/lib/shop_log_screen.dart @@ -11,6 +11,7 @@ import 'services/product_repository.dart'; import 'shop_detail_screen.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; +import 'widgets/confirm_dialog.dart'; import 'widgets/settings_button.dart'; import 'widgets/store_tag.dart'; @@ -25,6 +26,7 @@ class ShopLogScreen extends StatefulWidget { class _ShopLogScreenState extends State { late Future> _future; + bool _editing = false; @override void initState() { @@ -39,15 +41,24 @@ class _ShopLogScreenState extends State { Future _openShop(Shop shop) async { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ShopDetailScreen( - repository: widget.repository, - shopId: shop.id, - ), + builder: (_) => + ShopDetailScreen(repository: widget.repository, shopId: shop.id), ), ); if (mounted) _reload(); } + Future _removeShop(Shop shop) async { + final confirmed = await confirmAction( + context, + title: 'Remove shopping trip?', + message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.', + ); + if (!confirmed || !mounted) return; + await widget.repository.deleteShop(shop.id); + if (mounted) _reload(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -56,7 +67,14 @@ class _ShopLogScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Shop log'), - actions: const [SettingsButton()], + actions: [ + IconButton( + tooltip: _editing ? 'Done' : 'Edit', + onPressed: () => setState(() => _editing = !_editing), + icon: Icon(_editing ? Icons.check : Icons.edit_outlined), + ), + const SettingsButton(), + ], ), body: FutureBuilder>( future: _future, @@ -77,7 +95,7 @@ class _ShopLogScreenState extends State { if (shops.isEmpty) { return Center( child: Text( - 'Scan a receipt to log a shopping trip.', + 'Scan a receipt or enter a trip to log it.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), @@ -103,11 +121,17 @@ class _ShopLogScreenState extends State { count == 1 ? '1 product' : '$count products', ].join(' · '), ), - trailing: Text( - formatEuro(shop.total), - style: theme.textTheme.titleMedium, - ), - onTap: () => _openShop(shop), + trailing: _editing + ? IconButton( + tooltip: 'Remove', + onPressed: () => _removeShop(shop), + icon: const Icon(Icons.delete_outline), + ) + : Text( + formatEuro(shop.total), + style: theme.textTheme.titleMedium, + ), + onTap: _editing ? null : () => _openShop(shop), ); }, ); diff --git a/lib/shopping_list_detail_screen.dart b/lib/shopping_list_detail_screen.dart new file mode 100644 index 0000000..759ae5c --- /dev/null +++ b/lib/shopping_list_detail_screen.dart @@ -0,0 +1,316 @@ +/// Items on one shopping list, with check-off and edit/remove. +library; + +import 'package:flutter/material.dart'; + +import 'models/product.dart'; +import 'models/shopping_list.dart'; +import 'product_detail_screen.dart'; +import 'services/product_repository.dart'; +import 'widgets/confirm_dialog.dart'; +import 'widgets/name_dialog.dart'; + +class ShoppingListDetailScreen extends StatefulWidget { + const ShoppingListDetailScreen({ + super.key, + required this.repository, + required this.listId, + }); + + final ProductRepository repository; + final int listId; + + @override + State createState() => + _ShoppingListDetailScreenState(); +} + +class _ShoppingListDetailScreenState extends State { + late Future<({ShoppingList list, List items})> _future; + bool _editing = false; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<({ShoppingList list, List items})> _load() async { + final list = await widget.repository.getShoppingList(widget.listId); + if (list == null) { + throw StateError('List not found'); + } + final items = await widget.repository.getShoppingListItems(widget.listId); + return (list: list, items: items); + } + + void _reload() { + setState(() => _future = _load()); + } + + Future _rename(ShoppingList list) async { + final name = await showNameDialog( + context, + title: 'Rename list', + initial: list.name, + label: 'List name', + ); + if (name == null || !mounted) return; + await widget.repository.renameShoppingList(widget.listId, name); + if (mounted) _reload(); + } + + Future _openAddSheet() async { + final products = await widget.repository.getAll(); + if (!mounted) return; + final result = await showModalBottomSheet<({String name, int? productId})>( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => _AddListItemSheet(products: products), + ); + if (result == null || !mounted) return; + await widget.repository.addShoppingListItem( + listId: widget.listId, + name: result.name, + productId: result.productId, + ); + if (mounted) _reload(); + } + + Future _editItem(ShoppingListItem item) async { + final name = await showNameDialog( + context, + title: 'Edit item', + initial: item.name, + label: 'Item', + ); + if (name == null || !mounted) return; + await widget.repository.updateShoppingListItem(id: item.id, name: name); + if (mounted) _reload(); + } + + Future _removeItem(ShoppingListItem item) async { + final confirmed = await confirmAction( + context, + title: 'Remove item?', + message: '"${item.name}" will be removed from this list.', + confirmLabel: 'Remove', + ); + if (!confirmed || !mounted) return; + await widget.repository.deleteShoppingListItem(item.id); + if (mounted) _reload(); + } + + Future _toggle(ShoppingListItem item) async { + await widget.repository.updateShoppingListItem( + id: item.id, + checked: !item.checked, + ); + if (mounted) _reload(); + } + + 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( + body: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: Text( + 'Could not load this list.', + style: theme.textTheme.bodyLarge, + ), + ), + ); + } + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), + ); + } + + final list = snapshot.data!.list; + final items = snapshot.data!.items; + + return Scaffold( + appBar: AppBar( + title: Text(list.name), + actions: [ + IconButton( + tooltip: 'Rename', + onPressed: () => _rename(list), + icon: const Icon(Icons.drive_file_rename_outline), + ), + IconButton( + tooltip: _editing ? 'Done' : 'Edit', + onPressed: () => setState(() => _editing = !_editing), + icon: Icon(_editing ? Icons.check : Icons.edit_outlined), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _openAddSheet, + tooltip: 'Add item', + child: const Icon(Icons.add), + ), + body: items.isEmpty + ? Center( + child: Text( + 'Add things to buy.', + style: theme.textTheme.bodyLarge, + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( + leading: Checkbox( + value: item.checked, + onChanged: _editing ? null : (_) => _toggle(item), + ), + title: Text( + item.name, + style: item.checked + ? theme.textTheme.bodyLarge?.copyWith( + decoration: TextDecoration.lineThrough, + color: theme.colorScheme.onSurfaceVariant, + ) + : null, + ), + trailing: _editing + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Edit', + onPressed: () => _editItem(item), + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Remove', + onPressed: () => _removeItem(item), + icon: const Icon(Icons.delete_outline), + ), + ], + ) + : item.productId == null + ? null + : IconButton( + tooltip: 'Product', + onPressed: () => _openProduct(item.productId!), + icon: const Icon(Icons.chevron_right), + ), + onTap: _editing ? null : () => _toggle(item), + ); + }, + ), + ); + }, + ), + ); + } +} + +class _AddListItemSheet extends StatefulWidget { + const _AddListItemSheet({required this.products}); + + final List products; + + @override + State<_AddListItemSheet> createState() => _AddListItemSheetState(); +} + +class _AddListItemSheetState extends State<_AddListItemSheet> { + final TextEditingController _controller = TextEditingController(); + String? _error; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + List get _matches { + final query = _controller.text.trim().toLowerCase(); + if (query.isEmpty) { + return widget.products.take(8).toList(); + } + return widget.products + .where((product) => product.name.toLowerCase().contains(query)) + .take(8) + .toList(); + } + + void _add({String? name, int? productId}) { + final value = (name ?? _controller.text).trim(); + if (value.isEmpty) { + setState(() => _error = 'Enter an item name.'); + return; + } + Navigator.of(context).pop((name: value, productId: productId)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bottom = MediaQuery.viewInsetsOf(context).bottom; + final matches = _matches; + + return Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Add item', style: theme.textTheme.titleLarge), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Item name'), + onChanged: (_) => setState(() => _error = null), + onSubmitted: (_) => _add(), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ], + if (matches.isNotEmpty) ...[ + const SizedBox(height: 12), + Text('From products', style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + for (final product in matches) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(product.name), + onTap: () => _add(name: product.name, productId: product.id), + ), + ], + const SizedBox(height: 8), + FilledButton(onPressed: () => _add(), child: const Text('Add')), + ], + ), + ); + } +} diff --git a/lib/shopping_lists_screen.dart b/lib/shopping_lists_screen.dart new file mode 100644 index 0000000..8c7391f --- /dev/null +++ b/lib/shopping_lists_screen.dart @@ -0,0 +1,151 @@ +/// Named shopping lists of things to buy. +library; + +import 'package:flutter/material.dart'; + +import 'models/shopping_list.dart'; +import 'services/product_repository.dart'; +import 'shopping_list_detail_screen.dart'; +import 'widgets/confirm_dialog.dart'; +import 'widgets/name_dialog.dart'; +import 'widgets/settings_button.dart'; + +class ShoppingListsScreen extends StatefulWidget { + const ShoppingListsScreen({super.key, required this.repository}); + + final ProductRepository repository; + + @override + State createState() => _ShoppingListsScreenState(); +} + +class _ShoppingListsScreenState extends State { + late Future> _future; + bool _editing = false; + + @override + void initState() { + super.initState(); + _future = widget.repository.getShoppingLists(); + } + + void _reload() { + setState(() => _future = widget.repository.getShoppingLists()); + } + + Future _create() async { + final name = await showNameDialog( + context, + title: 'New shopping list', + label: 'List name', + confirmLabel: 'Create', + ); + if (name == null || !mounted) return; + final id = await widget.repository.createShoppingList(name); + if (!mounted) return; + await _openList(id); + } + + Future _openList(int id) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ShoppingListDetailScreen(repository: widget.repository, listId: id), + ), + ); + if (mounted) _reload(); + } + + Future _removeList(ShoppingList list) async { + final confirmed = await confirmAction( + context, + title: 'Delete shopping list?', + message: '"${list.name}" and its items will be deleted.', + ); + if (!confirmed || !mounted) return; + await widget.repository.deleteShoppingList(list.id); + if (mounted) _reload(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Shopping lists'), + actions: [ + IconButton( + tooltip: _editing ? 'Done' : 'Edit', + onPressed: () => setState(() => _editing = !_editing), + icon: Icon(_editing ? Icons.check : Icons.edit_outlined), + ), + const SettingsButton(), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _create, + tooltip: 'New list', + child: const Icon(Icons.add), + ), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text( + 'Could not load shopping lists.', + style: theme.textTheme.bodyLarge, + ), + ); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final lists = snapshot.data!; + if (lists.isEmpty) { + return Center( + child: Text( + 'Create a list for your next shop.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), + itemCount: lists.length, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final list = lists[index]; + final remaining = list.itemCount - list.checkedCount; + return ListTile( + leading: CircleAvatar(child: Text('${list.itemCount}')), + title: Text(list.name), + subtitle: Text( + list.itemCount == 0 + ? 'Empty' + : remaining == 0 + ? 'All done' + : remaining == 1 + ? '1 item left' + : '$remaining items left', + ), + trailing: _editing + ? IconButton( + tooltip: 'Delete', + onPressed: () => _removeList(list), + icon: const Icon(Icons.delete_outline), + ) + : const Icon(Icons.chevron_right), + onTap: _editing ? null : () => _openList(list.id), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/utils/supermarkets.dart b/lib/utils/supermarkets.dart index 53e53fd..884678e 100644 --- a/lib/utils/supermarkets.dart +++ b/lib/utils/supermarkets.dart @@ -17,13 +17,18 @@ final List kDefaultSupermarkets = [ 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 Supermarket( + name: kOtherSupermarket, + colorValue: 0xFF78909C, + sortOrder: 11, + ), ]; const Map> kSupermarketAliases = { 'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'], 'Jumbo': ['JUMBO'], 'Lidl': ['LIDL'], + 'Kruidvat': ['KRUIDVAT'], 'Aldi': ['ALDI'], 'Plus': ['PLUS'], 'Dirk': ['DIRK'], @@ -31,6 +36,10 @@ const Map> kSupermarketAliases = { 'SPAR': ['SPAR'], 'Nettorama': ['NETTORAMA'], 'Hoogvliet': ['HOOGVLIET'], + 'Ekoplaza': ['EKOPLAZA'], + 'Action': ['ACTION'], + 'Etos': ['ETOS'], + 'World Toko': ['WORLD TOKO'], 'Picnic': ['PICNIC'], }; @@ -51,20 +60,20 @@ const List kSupermarkets = [ ]; /// Returns a supermarket name if it appears in [rawText]. -String? detectSupermarket( - String rawText, { - Iterable names = const [], -}) { +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)); + 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)) { + if (RegExp( + '\\b${RegExp.escape(name)}\\b', + caseSensitive: false, + ).hasMatch(rawText)) { return name; } } diff --git a/lib/widgets/confirm_dialog.dart b/lib/widgets/confirm_dialog.dart new file mode 100644 index 0000000..f1cff18 --- /dev/null +++ b/lib/widgets/confirm_dialog.dart @@ -0,0 +1,32 @@ +/// Confirm a destructive action before it runs. +library; + +import 'package:flutter/material.dart'; + +Future confirmAction( + BuildContext context, { + required String title, + required String message, + String confirmLabel = 'Delete', +}) async { + final result = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), + ], + ); + }, + ); + return result == true; +} diff --git a/lib/widgets/item_edit_sheet.dart b/lib/widgets/item_edit_sheet.dart new file mode 100644 index 0000000..e320d86 --- /dev/null +++ b/lib/widgets/item_edit_sheet.dart @@ -0,0 +1,113 @@ +/// Bottom sheet to add or edit a priced line item. +library; + +import 'package:flutter/material.dart'; + +import '../models/line_item.dart'; +import '../utils/money.dart'; + +Future showItemEditSheet(BuildContext context, {LineItem? item}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => ItemEditSheet(item: item), + ); +} + +class ItemEditSheet extends StatefulWidget { + const ItemEditSheet({super.key, this.item}); + + final LineItem? item; + + @override + State createState() => _ItemEditSheetState(); +} + +class _ItemEditSheetState extends State { + late final TextEditingController _nameController; + late final TextEditingController _priceController; + String? _error; + + @override + void initState() { + super.initState(); + final item = widget.item; + _nameController = TextEditingController(text: item?.name ?? ''); + _priceController = TextEditingController( + text: item == null ? '' : item.price.toStringAsFixed(2), + ); + } + + @override + void dispose() { + _nameController.dispose(); + _priceController.dispose(); + super.dispose(); + } + + void _save() { + final name = _nameController.text.trim(); + final price = tryParsePrice(_priceController.text); + if (name.isEmpty || price == null) { + setState(() => _error = 'Enter a product name and a price.'); + return; + } + final rounded = roundMoney(price); + final quantity = widget.item?.quantity; + Navigator.of(context).pop( + LineItem( + id: + widget.item?.id ?? + 'manual-${DateTime.now().microsecondsSinceEpoch}', + name: name, + price: rounded, + quantity: quantity, + unitPrice: quantity != null && quantity > 0 + ? roundMoney(rounded / quantity) + : widget.item?.unitPrice, + ), + ); + } + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.viewInsetsOf(context).bottom; + + return Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.item == null ? 'Add item' : 'Edit item', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _nameController, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Product name'), + autofocus: widget.item == null, + ), + const SizedBox(height: 12), + TextField( + controller: _priceController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Price'), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 16), + FilledButton(onPressed: _save, child: const Text('Save')), + ], + ), + ); + } +} diff --git a/lib/widgets/name_dialog.dart b/lib/widgets/name_dialog.dart new file mode 100644 index 0000000..194f037 --- /dev/null +++ b/lib/widgets/name_dialog.dart @@ -0,0 +1,101 @@ +/// Name prompt that owns its text controller for the dialog lifetime. +library; + +import 'package:flutter/material.dart'; + +Future showNameDialog( + BuildContext context, { + required String title, + String? initial, + String label = 'Name', + String confirmLabel = 'Save', +}) { + return showDialog( + context: context, + builder: (context) => _NameDialog( + title: title, + initial: initial ?? '', + label: label, + confirmLabel: confirmLabel, + ), + ); +} + +class _NameDialog extends StatefulWidget { + const _NameDialog({ + required this.title, + required this.initial, + required this.label, + required this.confirmLabel, + }); + + final String title; + final String initial; + final String label; + final String confirmLabel; + + @override + State<_NameDialog> createState() => _NameDialogState(); +} + +class _NameDialogState extends State<_NameDialog> { + late final TextEditingController _controller; + String? _error; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initial); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _save() { + final name = _controller.text.trim(); + if (name.isEmpty) { + setState(() => _error = 'Enter a name.'); + return; + } + Navigator.of(context).pop(name); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration(labelText: widget.label), + onSubmitted: (_) => _save(), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton(onPressed: _save, child: Text(widget.confirmLabel)), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 66422ce..a3bfaed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.dev" + source: hosted + version: "4.2.0" args: dependency: transitive description: @@ -65,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crop_your_image: + dependency: "direct main" + description: + name: crop_your_image + sha256: "14c8977b11a009dc5e73e0f6522970f93363e38183f1b2ffefe1676dc9c3f49d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" cross_file: dependency: transitive description: @@ -280,6 +296,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" image_picker: dependency: "direct main" description: @@ -536,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2540897..d1b1a09 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: sqflite: ^2.4.2 file_picker: ^12.1.2 share_plus: ^13.3.0 + crop_your_image: ^2.0.0 dev_dependencies: flutter_test: @@ -28,3 +29,4 @@ flutter: uses-material-design: true assets: - assets/receipt.jpeg + - receipt-parsing-keywords.json diff --git a/receipt-parsing-keywords.json b/receipt-parsing-keywords.json new file mode 100644 index 0000000..729c5e0 --- /dev/null +++ b/receipt-parsing-keywords.json @@ -0,0 +1,186 @@ +{ + "_comment": "Keyword/pattern config for the receipt parser. See docs/receipt-parsing.md to add a new shop. Match rules are implemented in receipt_parse_config.dart (short tokens use word boundaries; longer tokens use substring + compact OCR form).", + + "known_stores": { + "_comment": "Used to detect store name from the header block (first 1-5 lines). Match against the STORE_HEADER lines.", + "list": [ + "JUMBO", + "LIDL", + "KRUIDVAT", + "ALBERT HEIJN", + "AH", + "ALDI", + "PLUS", + "DIRK", + "COOP", + "SPAR", + "VOMAR", + "EKOPLAZA", + "HOOGVLIET", + "ACTION", + "ETOS", + "WORLD TOKO" + ], + "fallback_rule": "If no known store name matches, take the first non-address, non-phone-number line in the header block as a best-effort store name (free text)." + }, + + "column_header_keywords": { + "_comment": "Lines matching these are table headers, not data — ignore entirely.", + "list": [ + "OMSCHRIJVING", + "BEDRAG IN", + "ARTIKEL", + "PRIJS", + "AANTAL ARTIKEL", + "BESCHRIJVING" + ], + "separator_pattern": "^[=\\-#]{3,}$" + }, + + "quantity_modifier_pattern": { + "_comment": "Regex to detect an 'N X price' line, optionally followed by a unit word. Group 1 = quantity, group 2 = unit price.", + "regex": "^\\s*(\\d+)\\s*[xX]\\s*(\\d+[,.]\\d{2})\\s*(PER\\s+STUK|PER\\s+KG|ST\\.?|STUKS?)?\\s*$", + "inline_regex": "(\\d+)\\s*[xX]\\s*(\\d+[,.]\\d{2})\\s*(PER\\s+STUK|PER\\s+KG)?", + "note": "Use 'regex' for standalone quantity lines (Jumbo-style). Use 'inline_regex' to detect the same pattern embedded within a product-name line (Kruidvat-style)." + }, + + "discount_keywords": { + "_comment": "Lines matching ANY of these AND carrying a negative amount are discounts, not products.", + "list": [ + "ACTIE", + "KORTING", + "IN PRIJS VERLAAGD", + "BONUSKORTING", + "KASSAKORTING", + "ZEGELKORTING", + "REDUCTIE", + "AANBIEDING" + ], + "negative_amount_pattern": "-\\d+[,.]\\d{2}", + "receipt_level_total_discount_keywords": [ + "TOTAAL KORTING", + "TOTALE KORTING", + "TOTAAL BESPARING" + ] + }, + + "total_keywords": { + "_comment": "Order matters for disambiguation — check subtotal_keywords before grand_total_keywords, since 'SUBTOTAAL' also contains 'TOTAAL'.", + "subtotal_keywords": [ + "SUBTOTAAL", + "SUB TOTAAL" + ], + "grand_total_keywords": [ + "TOTAAL", + "TOTAL" + ], + "tax_inclusive_total_keywords": [ + "TOTAAL INCL. BTW", + "TOTAAL INCL BTW", + "TOTAAL EXCL. BTW", + "TOTAAL EXCL BTW" + ], + "rule": "The FIRST grand_total match encountered after the last item line is the authoritative receipt total. Any match containing a discount keyword (see discount_keywords.receipt_level_total_discount_keywords) is the total discount amount, not the grand total — check discount keywords first." + }, + + "payment_keywords": { + "_comment": "Lines matching these mark the start of the payment block. Everything from the first match onward (until noise/footer) should be excluded from item parsing, though some fields can be stored as receipt metadata.", + "list": [ + "BETAALD", + "BETALING", + "VISA", + "MASTERCARD", + "MAESTRO", + "V PAY", + "PIN", + "CONTACTLOZE BETALING", + "AKKOORD", + "KAARTNR", + "KAART:", + "TRANSACTIE", + "TERMINAL", + "MERCHANT", + "AUTH. CODE", + "AUTH CODE", + "KAARTHOUDER", + "CVM", + "CHIP", + "PAY", + "IDEAL" + ], + "metadata_fields_to_extract": [ + "payment_method (e.g. VISA, MASTERCARD, PIN, iDEAL)", + "transaction_id", + "auth_code", + "terminal_id" + ] + }, + + "tax_breakdown_keywords": { + "_comment": "Lines describing BTW/tax categories, not products. Also used to detect the trailing tax-category letter appended to some item lines.", + "list": [ + "BTW", + "BTW-CODE", + "BEDR.EXCL", + "BEDR.INCL", + "BEDRAG EXCL", + "BEDRAG INCL" + ], + "category_row_pattern": "^[A-Z]\\s+\\d+%", + "trailing_category_letter_pattern": "\\s[A-Z]$", + "note": "trailing_category_letter_pattern strips a single trailing tax-category letter (e.g. ' B' or ' C') from the end of an item line if present, before extracting the price. Do not treat it as part of the price." + }, + + "noise_footer_keywords": { + "_comment": "Lines matching these (or appearing after the payment block) are noise — ignore entirely.", + "list": [ + "BEWAAR", + "KASSABON", + "RETOURNEREN", + "RETOUR", + "DAGEN MET JE BON", + "WWW.", + ".COM", + ".NL", + "OPENINGSTIJDEN", + "MAANDAG", + "DINSDAG", + "WOENSDAG", + "DONDERDAG", + "VRIJDAG", + "ZATERDAG", + "ZONDAG", + "FACEBOOK", + "INSTAGRAM", + "BEDANKT", + "TOT ZIENS", + "MEDEWERKER", + "KASSA", + "TRANS", + "STORE", + "POS", + "REGISTREER", + "PLUS-APP", + "BESPAAR MEER", + "ZELF SERVICE", + "PRIVACY STATEMENT", + "KOOPZEGELS" + ] + }, + + "quantity_and_price_general": { + "price_pattern": "\\d+[,.]\\d{2}", + "decimal_separator": ",", + "note": "Some receipts (e.g. World Toko) show weight-based items like '900g', '500ml', '400g' as part of the product name — these are NOT quantities/units for the parser's purposes, just part of the descriptive name. Only treat a leading integer directly adjacent to 'X' or 'x' as a quantity." + }, + + "line_item_count_keywords": { + "_comment": "Some receipts show an item count line near the payment block, useful as a validation cross-check (does parsed item count match this?).", + "list": [ + "AANTAL ARTIKEL", + "AANTAL ARTIKELEN", + "ART.", + "ARTIKELEN" + ] + } +} diff --git a/receipt-parsing-spec.md b/receipt-parsing-spec.md new file mode 100644 index 0000000..1478b2b --- /dev/null +++ b/receipt-parsing-spec.md @@ -0,0 +1,278 @@ +# Receipt Parsing Logic Specification + +**Implementation guide (how to add a shop):** [`docs/receipt-parsing.md`](docs/receipt-parsing.md) +**Keyword config:** [`receipt-parsing-keywords.json`](receipt-parsing-keywords.json) + +Purpose: define rule-based (non-AI, regex/heuristic) logic for turning raw +OCR text lines from a supermarket receipt into structured data: items, +quantities, unit prices, line totals, discounts, and receipt totals. + +This spec is based on analysis of real receipts from multiple Dutch stores +(Jumbo, Lidl, Kruidvat, World Toko), which use at least four different line +layouts for the same underlying data. The parser must handle all of them +without assuming a single fixed format. + +--- + +## 1. Input assumptions + +- Input is raw OCR text, one line per detected text line, in top-to-bottom + order as it appeared on the receipt. +- Prices use European format: comma as decimal separator (e.g. `2,89`), + period as thousands separator if present (rare on receipts this size). +- OCR may introduce noise: misread characters, extra/missing spaces, + occasional dropped or merged lines. The parser should be tolerant of + minor spacing irregularities but does not need to do spell-correction. + +## 2. Preprocessing (run before classification) + +1. Trim leading/trailing whitespace on every line; collapse multiple + internal spaces to one. +2. Normalize decimal separators: treat `,` as the decimal point in any + number matching `\d+[,.]\d{2}`. Convert to a standard `.` internally + for numeric storage, but remember the original format was comma-based + (useful for locale-aware re-parsing if needed). +3. Drop fully empty lines. +4. Do NOT drop short lines outright (e.g. a lone `BROCCOLI` or a lone + `2 X 0,95`) — both are meaningful in different receipt layouts. + +## 3. Core line classification + +Classify every line into one of these categories, in this priority order +(check top-down, first match wins): + +### 3.1 STORE_HEADER +- The first 1–5 lines of the receipt, before the first recognizable + item/price line appears. +- Often contains a store name in large/stylized text (e.g. "JUMBO", + "Lidl", "Kruidvat"), an address, phone number, or website. +- Heuristic: lines before the first line matching an ITEM or + QUANTITY_MODIFIER pattern (see below), or before a line matching + `OMSCHRIJVING` / `Artikel` / similar column-header keywords. +- Extract the store name from this block using a known store-name list + (maintain and grow this list — seen so far: JUMBO, LIDL, KRUIDVAT, + ALBERT HEIJN, ALDI, and independent/ethnic grocers whose name appears + as free text, e.g. "World Toko" — for these, take the first non-address + line as a best-effort store name). + +### 3.2 COLUMN_HEADER (ignore, not an item) +- Lines that are just table headers, not data. +- Match keywords: `OMSCHRIJVING`, `BEDRAG IN`, `Artikel`, `Prijs`, + `Aantal Artikel`, or lines that are purely `=`, `-`, or `#` separator + characters. + +### 3.3 ITEM (product line) +- A line containing a product name AND a price at or near the end of + the line. +- Price pattern: `\d+[,.]\d{2}` anchored near line end (allow trailing + single-letter suffixes like ` B` or ` C` — see section 6, BTW category + codes — these are not part of the price). +- Everything before the matched price is the product name (trim + trailing spaces). +- This is the "complete" case: name + price on the same line, quantity + of 1 implied unless a QUANTITY_MODIFIER line follows (section 3.4). + +### 3.4 QUANTITY_MODIFIER (sub-line, belongs to previous or next ITEM) +- A line matching the pattern: ` X ` optionally followed by + a unit word (e.g. `PER STUK`), e.g.: + - `2 X 2,79` + - `2 X 3,99 PER STUK` +- This line does NOT introduce a new product — it modifies the item on + an adjacent line. +- Two observed variants — the parser must detect which applies: + - **Variant A (Jumbo-style):** product name appears on its own line + with NO price. The next line is `N X unit_price`. The line total + (`N × unit_price`) appears as its own value either on the same + `N X price` line's right-hand column, or as a separate line + immediately after. Associate this quantity/total back to the + product name line above it. + - **Variant B (Kruidvat-style):** product name and `N X unit_price` + appear on the SAME line, description-style (e.g. + `ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK`), with the computed total + price in a separate right-aligned amount column/line. +- In both variants: `quantity = N`, `unit_price = price`, + `line_total = N * unit_price` (compute this yourself, then cross-check + against any total value found nearby — if they don't match within + 0.01, flag the item for manual review rather than guessing). + +### 3.5 DISCOUNT +- A line representing a price reduction, not a product. +- Detection rules (match ANY of): + - Line starts with or contains `ACTIE` (Jumbo pattern), e.g. + `ACTIE AARDBEIEN` with an associated negative amount. + - Line contains `KORTING` (Kruidvat/general Dutch for "discount"), + e.g. `KORTING NEUTRAL ZE.HP`. + - Line contains `In prijs verlaagd` (Lidl pattern — "price reduced"). + - The associated amount is negative (prefixed with `-`), e.g. `-2,00`, + `-1,55`, `-0,21`, `-0,50`. +- A discount line should be linked to the item immediately preceding it + when possible (the discount usually applies to the last product + listed above it). Store it as a `discount` field on that item rather + than as a separate negative "product." +- If a discount line cannot be confidently linked to a specific item + (e.g. it's a receipt-wide discount near the total, like + `Totaal korting: -2,21`), store it as a receipt-level + `total_discount` field instead. + +### 3.6 SUBTOTAL / TOTAL +- Lines matching keywords: `Totaal`, `SUBTOTAAL`, `Total`, `TOTAAL`. +- There may be MULTIPLE lines containing "Totaal" on one receipt + (e.g. a running subtotal, then a final Totaal, then + "Totaal korting" near the bottom, then "Totaal incl. BTW"). Rules: + - The FIRST large bold "Totaal" line following the last item is the + **receipt grand total** — use this as the authoritative total to + validate your parsed items against. + - Any "Totaal korting" line is the **sum of all discounts** — use + this as a cross-check: sum of all `discount` fields you extracted + should equal this value (within 0.01). + - "Totaal incl. BTW" / "Totaal excl. BTW" relate to tax breakdown, + not needed for item-level data, but can be stored as metadata. + +### 3.7 PAYMENT (ignore for item extraction, optionally store as metadata) +- Lines matching: `Betaald`, `BETALING`, `VISA`, `Mastercard`, `PIN`, + `Contactloze betaling`, `AKKOORD`, `Kaartnr`, `Transactie`, + `Terminal`, `Merchant`, `Auth. code`, `Kaart`. +- These lines and everything below/around them (transaction IDs, auth + codes, terminal numbers) should be excluded from item parsing + entirely. Treat "first payment-related line after the last Totaal + line" as the end of the item-relevant section of the receipt. + +### 3.8 TAX / BTW BREAKDOWN (ignore for item extraction) +- Lines matching: `BTW`, `Bedr.Excl`, `Bedr.Incl`, a table with columns + like `B 9%` / `C 21%`, `BTW-Code`. +- These describe tax categories, not individual products. Some product + lines end in a single letter (`B` or `C`) marking their tax category + — strip this letter before/after price extraction, don't treat it as + part of the price or name. + +### 3.9 NOISE / FOOTER (ignore entirely) +- Loyalty program mentions, website URLs, "Bewaar uw kassabon", + return policy text, barcodes represented as text/numbers, store + hours, social media handles, "Bedankt en graag tot ziens", + promotional taglines. Match a broad keyword/pattern list and treat + anything after the payment block as low-priority/ignorable by default. + +## 4. Item construction algorithm (pseudocode) + +``` +items = [] +pending_item = None # name found, price/qty not yet resolved + +for line in classified_lines: + if line.type == ITEM: + if pending_item: + items.append(pending_item) # close out previous item first + pending_item = { name: line.name, unit_price: line.price, + quantity: 1, line_total: line.price, + discount: 0 } + + elif line.type == QUANTITY_MODIFIER: + if pending_item and pending_item.unit_price is None: + # Variant A: name-only line was pending, this line completes it + pending_item.quantity = line.qty + pending_item.unit_price = line.price + pending_item.line_total = line.qty * line.price + elif pending_item: + # Variant B: name+price already set, this line refines/ + # confirms it (overwrite quantity/unit price, recompute total) + pending_item.quantity = line.qty + pending_item.unit_price = line.price + pending_item.line_total = line.qty * line.price + else: + # Orphan quantity line with no preceding item — flag for + # manual review rather than silently dropping + flag_for_review(line) + + elif line.type == DISCOUNT: + if pending_item: + pending_item.discount += line.amount # amount is negative + pending_item.line_total += line.amount + else: + receipt.total_discount += line.amount + + elif line.type in (SUBTOTAL, TOTAL): + if pending_item: + items.append(pending_item) + pending_item = None + if is_grand_total(line): + receipt.total = line.amount + break # stop item parsing here + +# catch any trailing pending item if receipt ended oddly +if pending_item: + items.append(pending_item) +``` + +## 5. Validation pass (do this after parsing, before showing results to user) + +1. Sum `line_total` across all parsed items. +2. Add `receipt.total_discount` if it wasn't already folded into + individual item discounts. +3. Compare the result to `receipt.total` (the grand total line). +4. If they match within 0.01 → high confidence, no flags needed. +5. If they don't match → mark the WHOLE receipt as "needs review" and + surface it clearly in the review/edit screen (don't just flag + individual lines — a mismatch means something in the whole parse is + probably off, e.g. a missed line or a misclassified discount). + +## 6. Known store-specific quirks to encode as configurable rules + +Keep these as a lookup table / config, not hardcoded logic, so new +stores can be added without rewriting the parser: + +| Store | Quirk | +|---|---| +| Jumbo | Multi-unit items: name on own line, then `N X price` line, then total. Discounts prefixed `ACTIE ` with negative amount. Tax category letter (`B`/`C`) sometimes appended to far-right column, not the price. | +| Lidl | Discounts appear as `In prijs verlaagd -0,50` style lines. Tax breakdown at the bottom uses `Bedr.Excl` / `BTW` / `Bedr.Incl` labels with `B 9%` / `C 21%` category rows — do not parse these rows as products. | +| Kruidvat | Multi-unit items combine name + `N X price PER STUK` on one line. `KORTING ` lines carry the discount, may appear one or two lines after the item, not always immediately after. | +| World Toko | Simple `Artikel` / `Prijs` two-column layout, mostly one item per line with price directly alongside; some multi-unit items still use a following `N X price` line (Variant A). | + +Add new rows to this table as new receipt formats are encountered — this +is meant to grow with real-world use rather than be exhaustive on day one. + +## 7. Output schema (what the parser should produce per receipt) + +```json +{ + "store": "Jumbo", + "date": "2026-08-30", + "items": [ + { + "name": "Jumbo Bio Volle Melk", + "quantity": 1, + "unit_price": 2.09, + "discount": 0.00, + "line_total": 2.09 + }, + { + "name": "Biologisch Ei 6 st", + "quantity": 2, + "unit_price": 2.79, + "discount": 0.00, + "line_total": 5.58 + }, + { + "name": "Aardbeien", + "quantity": 2, + "unit_price": 3.99, + "discount": -2.00, + "line_total": 5.98 + } + ], + "total_discount": -2.21, + "receipt_total": 82.99, + "validation_passed": true +} +``` + +## 8. Explicit non-goals for this parser + +- No attempt to categorize items (groceries vs household, etc.) — + out of scope. +- No attempt to normalize/deduplicate product names across receipts at + parse time (e.g. matching "Jumbo Bio Volle Melk" to a previous + purchase) — that's a separate matching step done after storage, not + part of this parsing spec. +- No language detection/translation — assume Dutch keyword lists for + now (`ACTIE`, `KORTING`, `Totaal`, `Betaald`, etc.), but structure the + keyword lists as swappable config so other languages can be added. diff --git a/test/backup_format_test.dart b/test/backup_format_test.dart index 87c9ee7..2a196df 100644 --- a/test/backup_format_test.dart +++ b/test/backup_format_test.dart @@ -3,8 +3,8 @@ import 'package:receipity/services/backup_format.dart'; void main() { test('round-trips a backup document', () { - final original = const BackupDocument( - version: 1, + const original = BackupDocument( + version: 2, exportedAt: '2026-08-29T12:00:00.000', darkMode: true, supermarkets: [ @@ -46,6 +46,24 @@ void main() { 'unit_price': 1.5, }, ], + shoppingLists: [ + { + 'id': 1, + 'name': 'Weekly', + 'created_at': '2026-08-29T12:00:00.000', + 'updated_at': '2026-08-29T12:00:00.000', + }, + ], + shoppingListItems: [ + { + 'id': 1, + 'list_id': 1, + 'product_id': 1, + 'name': 'Milk', + 'checked': 0, + 'sort_order': 0, + }, + ], images: {'products/product_1.jpg': 'abc123'}, ); @@ -54,9 +72,31 @@ void main() { expect(parsed.products.single['name'], 'Milk'); expect(parsed.shops.single['id'], 8); expect(parsed.shopItems.single['product_id'], 1); + expect(parsed.shoppingLists.single['name'], 'Weekly'); + expect(parsed.shoppingListItems.single['name'], 'Milk'); expect(parsed.images['products/product_1.jpg'], 'abc123'); }); + test('accepts a version 1 backup without shopping lists', () { + const json = ''' +{ + "format": "receipity-backup", + "version": 1, + "exportedAt": "2026-08-29T12:00:00.000", + "darkMode": false, + "supermarkets": [], + "products": [], + "shops": [], + "shopItems": [], + "images": {} +} +'''; + final parsed = BackupDocument.parse(json); + expect(parsed.version, 1); + expect(parsed.shoppingLists, isEmpty); + expect(parsed.shoppingListItems, isEmpty); + }); + test('rejects a file that is not a Receipity backup', () { expect( () => BackupDocument.parse('{"hello":"world"}'), diff --git a/test/receipt_parser_test.dart b/test/receipt_parser_test.dart index adb1138..56e9308 100644 --- a/test/receipt_parser_test.dart +++ b/test/receipt_parser_test.dart @@ -44,17 +44,59 @@ BROODSTICKS OLIJF expect(items.single.quantity, 4); }); - test('drops discount rows and Dutch totals', () { - final items = ReceiptParser.parse(''' + test('folds a discount into the previous item', () { + final result = ReceiptParser.parseReceipt(''' AARDBEIEN 7,98 ACTIE AARDBEIEN -2,00 -Totaal 82,99 -VISA 82,99 -Totaal korting: -2,21 +Totaal 5,98 +VISA 5,98 +Totaal korting: -2,00 '''); - expect(items.map((item) => item.name), ['AARDBEIEN']); - expect(items.single.price, 7.98); + expect(result.items.map((item) => item.name), ['AARDBEIEN']); + expect(result.items.single.price, 5.98); + expect(result.items.single.discount, -2.00); + expect(result.validationPassed, isTrue); + }); + + test('parses Kruidvat-style inline quantity', () { + final items = ReceiptParser.parse(''' +ZEEPTABLET NEUTRAL 2 X 3,99 PER STUK 7,98 +KORTING NEUTRAL ZE.HP -1,00 +Totaal 6,98 +'''); + + expect(items, hasLength(1)); + expect(items.single.name, 'ZEEPTABLET NEUTRAL'); + expect(items.single.quantity, 2); + expect(items.single.unitPrice, 3.99); + expect(items.single.price, 6.98); + }); + + test('parses Lidl-style price-reduction lines', () { + final items = ReceiptParser.parse(''' +MELK 1,09 B +In prijs verlaagd -0,50 +Totaal 0,59 +'''); + + expect(items, hasLength(1)); + expect(items.single.name, 'MELK'); + expect(items.single.price, 0.59); + }); + + test('ignores BTW breakdown rows', () { + final items = ReceiptParser.parse(''' +MELK 2,09 +Totaal 2,09 +BTW +B 9% 0,17 +C 21% 0,00 +Bedr.Excl 1,92 +Bedr.Incl 2,09 +'''); + + expect(items.map((item) => item.name), ['MELK']); }); test('pairs a name column with a price column from OCR', () { @@ -66,10 +108,16 @@ Totaal korting: -2,21 expect(items.first.name, 'JUMBO BIO VOLLE MELK'); expect(items.first.price, 2.09); - final eggs = items.firstWhere((item) => item.name.startsWith('BIOLOGISCH EI')); + final eggs = items.firstWhere( + (item) => item.name.startsWith('BIOLOGISCH EI'), + ); expect(eggs.quantity, 2); expect(eggs.price, 5.58); + final berries = items.firstWhere((item) => item.name == 'AARDBEIEN'); + expect(berries.quantity, 2); + expect(berries.price, 5.98); + expect(items.last.name, 'JUMBO PREM LUIER M4'); expect(items.last.price, 6.20); expect(items, hasLength(26)); diff --git a/test/widget_test.dart b/test/widget_test.dart index 3a92bc3..fab4fa3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -14,16 +14,33 @@ void main() { expect(find.text('Receipity'), findsOneWidget); expect(find.text('Take photo'), findsOneWidget); expect(find.text('Pick from gallery'), findsOneWidget); + expect(find.text('Enter manually'), findsOneWidget); expect(find.text('Use sample receipt'), findsOneWidget); expect( - find.textContaining('Take a photo of a shopping receipt'), + find.textContaining('Scan a receipt or enter a trip by hand'), findsOneWidget, ); expect(find.text('Products'), findsOneWidget); - expect(find.text('Scan'), findsOneWidget); + expect(find.text('Lists'), findsOneWidget); + expect(find.text('Add'), findsOneWidget); expect(find.text('Shop log'), findsOneWidget); }); + testWidgets('enter manually opens a blank trip', (tester) async { + await tester.pumpWidget(ReceipityApp()); + await tester.tap(find.text('Enter manually')); + await tester.pumpAndSettle(); + expect(find.text('New trip'), findsOneWidget); + expect(find.text('Add item'), findsOneWidget); + }); + + testWidgets('lists tab shows shopping lists', (tester) async { + await tester.pumpWidget(ReceipityApp()); + await tester.tap(find.text('Lists')); + await tester.pump(); + expect(find.text('Shopping lists'), findsOneWidget); + }); + testWidgets('review screen lists items and can delete one', (tester) async { await tester.pumpWidget( MaterialApp(