From 8b040e83c04dcd16057bc1700ed9cf967c0554a4 Mon Sep 17 00:00:00 2001 From: davmar Date: Thu, 3 Sep 2026 15:09:17 +0200 Subject: [PATCH] multiple receipts in a trip --- lib/products_screen.dart | 51 ++++--- lib/review_screen.dart | 118 +++++++++++++--- lib/services/receipt_capture.dart | 188 +++++++++++++++++++++++++ lib/services/receipt_parse_config.dart | 15 +- lib/shop_detail_screen.dart | 76 +++++++++- test/receipt_parser_test.dart | 13 ++ 6 files changed, 409 insertions(+), 52 deletions(-) create mode 100644 lib/services/receipt_capture.dart diff --git a/lib/products_screen.dart b/lib/products_screen.dart index e9365a2..9db8465 100644 --- a/lib/products_screen.dart +++ b/lib/products_screen.dart @@ -263,29 +263,34 @@ class _ProductsScreenState extends State { children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: ProductSort.name, - label: Text('A–Z'), - ), - ButtonSegment( - value: ProductSort.preferred, - label: Text('Frequent'), - ), - ButtonSegment( - value: ProductSort.favourite, - icon: Icon(Icons.star), - label: Text('Star'), - ), - ], - selected: {_sort}, - onSelectionChanged: (selected) { - setState(() { - _sort = selected.first; - }); - _load(); - }, + child: SizedBox( + width: double.infinity, + child: SegmentedButton( + expandedInsets: EdgeInsets.zero, + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: ProductSort.name, + label: Text('A–Z'), + ), + ButtonSegment( + value: ProductSort.preferred, + label: Text('Frequent'), + ), + ButtonSegment( + value: ProductSort.favourite, + icon: Icon(Icons.star), + label: Text('Star'), + ), + ], + selected: {_sort}, + onSelectionChanged: (selected) { + setState(() { + _sort = selected.first; + }); + _load(); + }, + ), ), ), if (_nameIssues.isNotEmpty && !_editing) diff --git a/lib/review_screen.dart b/lib/review_screen.dart index 546d2d1..fa865cd 100644 --- a/lib/review_screen.dart +++ b/lib/review_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'models/line_item.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; +import 'services/receipt_capture.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'utils/supermarkets.dart'; @@ -56,12 +57,19 @@ class _ReviewScreenState extends State { List _tagSuggestions = []; String? _highlightedItemId; bool _showTripExtras = false; + late String _rawText; + String? _imagePath; + late bool _needsReview; + bool _addingReceipt = false; @override void initState() { super.initState(); _items = List.from(widget.items); _shoppedAt = DateTime.now(); + _rawText = widget.rawText; + _imagePath = widget.imagePath; + _needsReview = widget.needsReview; final detected = detectSupermarket( widget.rawText, names: widget.supermarketNames, @@ -96,10 +104,56 @@ class _ReviewScreenState extends State { } bool get _hasReceipt { - final path = widget.imagePath; + final path = _imagePath; return path != null && path.isNotEmpty && File(path).existsSync(); } + Future _addReceipt() async { + if (_saving || _addingReceipt) return; + setState(() => _addingReceipt = true); + try { + final captured = await captureAndParseReceipt(context); + if (captured == null || !mounted) return; + + final keepExistingPhoto = _hasReceipt; + final merged = [ + for (final item in captured.items) + keepExistingPhoto + ? item.copyWith(clearSourceBounds: true) + : item, + ]; + + setState(() { + _items.addAll(merged); + _needsReview = _needsReview || captured.needsReview; + if (_rawText.isEmpty) { + _rawText = captured.rawText; + } else if (captured.rawText.isNotEmpty) { + _rawText = '$_rawText\n\n---\n\n${captured.rawText}'; + } + if (!keepExistingPhoto && captured.imagePath != null) { + _imagePath = captured.imagePath; + } + if (_supermarket == null && captured.detectedStore != null) { + _supermarket = captured.detectedStore; + } + _highlightedItemId = null; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + merged.length == 1 + ? 'Added 1 item from receipt.' + : 'Added ${merged.length} items from receipt.', + ), + ), + ); + } finally { + if (mounted) setState(() => _addingReceipt = false); + } + } + Future _edit({LineItem? existing}) async { final result = await showItemEditSheet(context, item: existing); if (result == null || !mounted) return; @@ -139,7 +193,7 @@ class _ReviewScreenState extends State { } Future _openReceiptPanel() async { - final path = widget.imagePath; + final path = _imagePath; if (path == null || path.isEmpty) return; final file = File(path); if (!file.existsSync()) return; @@ -198,7 +252,7 @@ class _ReviewScreenState extends State { } Future _openOcrPanel() async { - if (widget.rawText.isEmpty) return; + if (_rawText.isEmpty) return; await showModalBottomSheet( context: context, isScrollControlled: true, @@ -218,7 +272,7 @@ class _ReviewScreenState extends State { maxHeight: MediaQuery.sizeOf(context).height * 0.55, ), child: SingleChildScrollView( - child: SelectableText(widget.rawText), + child: SelectableText(_rawText), ), ), ], @@ -249,7 +303,7 @@ class _ReviewScreenState extends State { supermarket: supermarket, shoppedAt: _shoppedAt, items: _items, - receiptImagePath: widget.imagePath, + receiptImagePath: _imagePath, name: _tripName.text, tags: _tags, ); @@ -288,7 +342,7 @@ class _ReviewScreenState extends State { return Scaffold( appBar: AppBar( title: Text( - widget.rawText.isEmpty && widget.imagePath == null + _rawText.isEmpty && _imagePath == null ? 'New trip' : 'Review items', ), @@ -300,7 +354,7 @@ class _ReviewScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.needsReview) ...[ + if (_needsReview) ...[ Material( color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(12), @@ -489,7 +543,7 @@ class _ReviewScreenState extends State { ], ), ), - if (_hasReceipt || widget.rawText.isNotEmpty) + if (_hasReceipt || _rawText.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), child: Row( @@ -497,17 +551,21 @@ class _ReviewScreenState extends State { if (_hasReceipt) Expanded( child: OutlinedButton.icon( - onPressed: _saving ? null : _openReceiptPanel, + onPressed: _saving || _addingReceipt + ? null + : _openReceiptPanel, icon: const Icon(Icons.receipt_long_outlined, size: 18), label: const Text('Receipt'), ), ), - if (_hasReceipt && widget.rawText.isNotEmpty) + if (_hasReceipt && _rawText.isNotEmpty) const SizedBox(width: 8), - if (widget.rawText.isNotEmpty) + if (_rawText.isNotEmpty) Expanded( child: OutlinedButton.icon( - onPressed: _saving ? null : _openOcrPanel, + onPressed: _saving || _addingReceipt + ? null + : _openOcrPanel, icon: const Icon(Icons.text_snippet_outlined, size: 18), label: const Text('OCR text'), ), @@ -521,14 +579,40 @@ class _ReviewScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - OutlinedButton.icon( - onPressed: _saving ? null : () => _edit(), - icon: const Icon(Icons.add), - label: const Text('Add item'), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _saving || _addingReceipt + ? null + : () => _edit(), + icon: const Icon(Icons.add), + label: const Text('Add item'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _saving || _addingReceipt + ? null + : _addReceipt, + icon: _addingReceipt + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.receipt_long_outlined), + label: const Text('Add receipt'), + ), + ), + ], ), const SizedBox(height: 8), FilledButton( - onPressed: _saving ? null : _confirm, + onPressed: _saving || _addingReceipt ? null : _confirm, child: Text( _saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}', ), diff --git a/lib/services/receipt_capture.dart b/lib/services/receipt_capture.dart new file mode 100644 index 0000000..85cf7f1 --- /dev/null +++ b/lib/services/receipt_capture.dart @@ -0,0 +1,188 @@ +/// Shared pick → crop → OCR → parse flow for adding a receipt to a trip. +library; + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +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 'receipt_parser.dart'; + +class CapturedReceiptParse { + const CapturedReceiptParse({ + required this.items, + required this.rawText, + this.imagePath, + this.needsReview = false, + this.detectedStore, + }); + + final List items; + final String rawText; + final String? imagePath; + final bool needsReview; + final String? detectedStore; +} + +/// Asks camera vs gallery, crops, runs OCR, and returns parsed items. +/// +/// Returns null if the user cancels at any step. +Future captureAndParseReceipt( + BuildContext context, { + ImagePicker? picker, +}) async { + final source = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_camera_outlined), + title: const Text('Take photo'), + onTap: () => Navigator.pop(context, ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('Pick from gallery'), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ], + ), + ); + }, + ); + if (source == null || !context.mounted) return null; + + final imagePicker = picker ?? ImagePicker(); + final file = await imagePicker.pickImage(source: source, imageQuality: 95); + if (file == null || !context.mounted) return null; + + final croppedPath = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => CropReceiptScreen(imagePath: file.path), + ), + ); + if (croppedPath == null || !context.mounted) return null; + + return _runOcrAndParse(context, croppedPath); +} + +Future _runOcrAndParse( + BuildContext context, + String imagePath, +) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return const PopScope( + canPop: false, + child: Center( + child: Card( + child: Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Reading receipt…'), + ], + ), + ), + ), + ), + ); + }, + ); + + final recognizer = TextRecognizer(script: TextRecognitionScript.latin); + try { + final bytes = await File(imagePath).readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final imageWidth = frame.image.width.toDouble(); + final imageHeight = frame.image.height.toDouble(); + frame.image.dispose(); + + final recognized = await recognizer.processImage( + InputImage.fromFilePath(imagePath), + ); + final rawText = recognized.text.trim(); + if (rawText.isEmpty) { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No text found in this image.')), + ); + } + return null; + } + + final parsed = ReceiptParser.parseRecognizedText( + recognized, + imageWidth: imageWidth, + imageHeight: imageHeight, + ); + + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + if (parsed.items.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No products found on this receipt.')), + ); + } + return null; + } + + // Fresh ids so merged trips never collide with existing item keys. + final stamp = DateTime.now().microsecondsSinceEpoch; + final items = [ + for (var i = 0; i < parsed.items.length; i++) + parsed.items[i].copyWith(id: 'merged-$stamp-$i'), + ]; + + return CapturedReceiptParse( + items: items, + rawText: rawText, + imagePath: imagePath, + needsReview: !parsed.validationPassed, + detectedStore: parsed.store, + ); + } on PlatformException catch (error) { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Could not read text from this image.' + '${error.message == null || error.message!.isEmpty ? '' : '\n${error.message}'}', + ), + ), + ); + } + return null; + } catch (error) { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not read text from this image.\n$error')), + ); + } + return null; + } finally { + await recognizer.close(); + } +} diff --git a/lib/services/receipt_parse_config.dart b/lib/services/receipt_parse_config.dart index 39084f0..b959e24 100644 --- a/lib/services/receipt_parse_config.dart +++ b/lib/services/receipt_parse_config.dart @@ -132,20 +132,19 @@ class ReceiptParseConfig { /// 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`. + /// Always uses word boundaries so `CHIP` does not match `CHIPS` and `PIN` + /// does not match `SPINAZIE`. A letter-only compact form is a fallback for + /// OCR splits like `Betaal d:` → `BETAALD` (only for keywords of length ≥ 5, + /// so short tokens like `CHIP` cannot match inside longer product words). 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 (RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper)) { + return true; } - 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); + return compactKey.length >= 5 && compactLine.contains(compactKey); } bool isStandaloneKeywordLine(String line, List keywords) { diff --git a/lib/shop_detail_screen.dart b/lib/shop_detail_screen.dart index c4236ee..de33e12 100644 --- a/lib/shop_detail_screen.dart +++ b/lib/shop_detail_screen.dart @@ -9,6 +9,7 @@ import 'models/line_item.dart'; import 'models/shop.dart'; import 'product_detail_screen.dart'; import 'services/product_repository.dart'; +import 'services/receipt_capture.dart'; import 'utils/dates.dart'; import 'utils/money.dart'; import 'widgets/confirm_dialog.dart'; @@ -37,6 +38,7 @@ class _ShopDetailScreenState extends State { bool _editing = false; List _tagSuggestions = []; int _loadToken = 0; + bool _addingReceipt = false; @override void initState() { @@ -150,6 +152,42 @@ class _ShopDetailScreenState extends State { } } + Future _addFromReceipt() async { + final shop = _shop; + if (shop == null || _addingReceipt) return; + setState(() => _addingReceipt = true); + try { + final captured = await captureAndParseReceipt(context); + if (captured == null || !mounted) return; + + var added = 0; + for (final item in captured.items) { + await widget.repository.addShopItem(shopId: shop.id, item: item); + added++; + } + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added == 1 + ? 'Added 1 item from receipt.' + : 'Added $added items from receipt.', + ), + ), + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not add items from this receipt.')), + ); + _load(); + } finally { + if (mounted) setState(() => _addingReceipt = false); + } + } + Future _editItem(ShopItem item) async { final result = await showItemEditSheet( context, @@ -299,11 +337,41 @@ class _ShopDetailScreenState extends State { ), ], ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: _editing - ? FloatingActionButton.extended( - onPressed: _addItem, - icon: const Icon(Icons.add), - label: const Text('Add product'), + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + onPressed: _addingReceipt ? null : _addFromReceipt, + icon: _addingReceipt + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.receipt_long_outlined), + label: const Text('Add receipt'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: _addingReceipt ? null : _addItem, + icon: const Icon(Icons.add), + label: const Text('Add product'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + ), + ), + ], + ), ) : null, body: Builder( diff --git a/test/receipt_parser_test.dart b/test/receipt_parser_test.dart index 81d53b4..f01c296 100644 --- a/test/receipt_parser_test.dart +++ b/test/receipt_parser_test.dart @@ -154,6 +154,19 @@ Bedr.Incl 2,09 expect(items, hasLength(26)); }); + test('does not treat CHIPS as payment keyword CHIP', () { + final items = ReceiptParser.parse(''' +AH Protein +AH Chips +1,49 +0.69 +'''); + + expect(items, hasLength(2)); + expect(items.map((item) => item.name), ['AH Protein', 'AH Chips']); + expect(items.map((item) => item.price), [1.49, 0.69]); + }); + test('scan path uses column parser for Jumbo name-then-price OCR dump', () { const ocr = ''' BIO VASTKOKEND AARD