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
+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)}',
),