1 Commits
Author SHA1 Message Date
davmar 8b040e83c0 multiple receipts in a trip 2026-09-03 15:09:17 +02:00
6 changed files with 409 additions and 52 deletions
+28 -23
View File
@@ -263,29 +263,34 @@ class _ProductsScreenState extends State<ProductsScreen> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: SegmentedButton<ProductSort>(
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<ProductSort>(
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)
+101 -17
View File
@@ -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<ReviewScreen> {
List<String> _tagSuggestions = [];
String? _highlightedItemId;
bool _showTripExtras = false;
late String _rawText;
String? _imagePath;
late bool _needsReview;
bool _addingReceipt = false;
@override
void initState() {
super.initState();
_items = List<LineItem>.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<ReviewScreen> {
}
bool get _hasReceipt {
final path = widget.imagePath;
final path = _imagePath;
return path != null && path.isNotEmpty && File(path).existsSync();
}
Future<void> _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<void> _edit({LineItem? existing}) async {
final result = await showItemEditSheet(context, item: existing);
if (result == null || !mounted) return;
@@ -139,7 +193,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
}
Future<void> _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<ReviewScreen> {
}
Future<void> _openOcrPanel() async {
if (widget.rawText.isEmpty) return;
if (_rawText.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@@ -218,7 +272,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
maxHeight: MediaQuery.sizeOf(context).height * 0.55,
),
child: SingleChildScrollView(
child: SelectableText(widget.rawText),
child: SelectableText(_rawText),
),
),
],
@@ -249,7 +303,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
supermarket: supermarket,
shoppedAt: _shoppedAt,
items: _items,
receiptImagePath: widget.imagePath,
receiptImagePath: _imagePath,
name: _tripName.text,
tags: _tags,
);
@@ -288,7 +342,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
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<ReviewScreen> {
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<ReviewScreen> {
],
),
),
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<ReviewScreen> {
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<ReviewScreen> {
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)}',
),
+188
View File
@@ -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<LineItem> 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<CapturedReceiptParse?> captureAndParseReceipt(
BuildContext context, {
ImagePicker? picker,
}) async {
final source = await showModalBottomSheet<ImageSource>(
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<String>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => CropReceiptScreen(imagePath: file.path),
),
);
if (croppedPath == null || !context.mounted) return null;
return _runOcrAndParse(context, croppedPath);
}
Future<CapturedReceiptParse?> _runOcrAndParse(
BuildContext context,
String imagePath,
) async {
showDialog<void>(
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();
}
}
+7 -8
View File
@@ -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<String> keywords) {
+72 -4
View File
@@ -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<ShopDetailScreen> {
bool _editing = false;
List<String> _tagSuggestions = [];
int _loadToken = 0;
bool _addingReceipt = false;
@override
void initState() {
@@ -150,6 +152,42 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
}
}
Future<void> _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<void> _editItem(ShopItem item) async {
final result = await showItemEditSheet(
context,
@@ -299,11 +337,41 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
),
],
),
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(
+13
View File
@@ -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