Files
receipity/lib/services/receipt_capture.dart
T
2026-09-03 15:09:17 +02:00

189 lines
5.3 KiB
Dart

/// 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();
}
}