189 lines
5.3 KiB
Dart
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();
|
|
}
|
|
}
|