multiple receipts in a trip

This commit is contained in:
davmar
2026-09-03 15:09:17 +02:00
parent ae42ba8193
commit 8b040e83c0
6 changed files with 409 additions and 52 deletions
+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) {