parser logic added

This commit is contained in:
davmar
2026-08-30 12:24:29 +02:00
parent 35d5c90d81
commit 3b698a3667
28 changed files with 3018 additions and 346 deletions
+46 -117
View File
@@ -9,6 +9,7 @@ import 'services/product_repository.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/item_edit_sheet.dart';
import 'widgets/store_tag.dart';
class ReviewScreen extends StatefulWidget {
@@ -19,6 +20,8 @@ class ReviewScreen extends StatefulWidget {
required this.repository,
this.imagePath,
this.supermarketNames = kSupermarkets,
this.needsReview = false,
this.detectedStore,
});
final List<LineItem> items;
@@ -26,6 +29,8 @@ class ReviewScreen extends StatefulWidget {
final ProductRepository repository;
final String? imagePath;
final List<String> supermarketNames;
final bool needsReview;
final String? detectedStore;
@override
State<ReviewScreen> createState() => _ReviewScreenState();
@@ -49,6 +54,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
);
if (detected != null) {
_supermarket = detected;
} else if (widget.detectedStore != null) {
_supermarket = widget.detectedStore;
}
}
@@ -63,12 +70,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
}
Future<void> _edit({LineItem? existing}) async {
final result = await showModalBottomSheet<LineItem>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _ItemEditSheet(item: existing),
);
final result = await showItemEditSheet(context, item: existing);
if (result == null || !mounted) return;
setState(() {
@@ -97,9 +99,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
Future<void> _confirm() async {
final supermarket = _resolvedSupermarket;
if (supermarket == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select a supermarket.')),
);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Select a supermarket.')));
return;
}
if (_items.isEmpty) {
@@ -142,7 +143,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
}
return Scaffold(
appBar: AppBar(title: const Text('Review items')),
appBar: AppBar(
title: Text(
widget.rawText.isEmpty && widget.imagePath == null
? 'New trip'
: 'Review items',
),
),
body: Column(
children: [
Padding(
@@ -150,13 +157,27 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.needsReview) ...[
Material(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Parsed totals do not match the receipt. Check the items before saving.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
),
const SizedBox(height: 12),
],
DropdownButtonFormField<String>(
key: ValueKey(_supermarket),
initialValue: _supermarket,
hint: const Text('Select supermarket'),
decoration: const InputDecoration(
labelText: 'Supermarket',
),
decoration: const InputDecoration(labelText: 'Supermarket'),
items: [
for (final store in names)
DropdownMenuItem(
@@ -177,9 +198,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
TextField(
enabled: !_saving,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Store name',
),
decoration: const InputDecoration(labelText: 'Store name'),
onChanged: (value) => _customStore = value,
),
],
@@ -198,7 +217,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
child: _items.isEmpty
? Center(
child: Text(
'No line items found.\nAdd anything the parser missed.',
'No line items yet.\nAdd products for this trip.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
@@ -231,11 +250,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
},
child: ListTile(
title: Text(item.name),
subtitle: item.quantity == null
? null
: Text(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
),
subtitle: _itemSubtitle(item),
trailing: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
@@ -274,9 +289,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
FilledButton(
onPressed: _saving ? null : _confirm,
child: Text(
_saving
? 'Saving…'
: 'Save trip · ${formatEuro(total)}',
_saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
),
),
],
@@ -287,98 +300,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
),
);
}
}
class _ItemEditSheet extends StatefulWidget {
const _ItemEditSheet({this.item});
final LineItem? item;
@override
State<_ItemEditSheet> createState() => _ItemEditSheetState();
}
class _ItemEditSheetState extends State<_ItemEditSheet> {
late final TextEditingController _nameController;
late final TextEditingController _priceController;
String? _error;
@override
void initState() {
super.initState();
final item = widget.item;
_nameController = TextEditingController(text: item?.name ?? '');
_priceController = TextEditingController(
text: item == null ? '' : item.price.toStringAsFixed(2),
);
}
@override
void dispose() {
_nameController.dispose();
_priceController.dispose();
super.dispose();
}
void _save() {
final name = _nameController.text.trim();
final price = tryParsePrice(_priceController.text);
if (name.isEmpty || price == null) {
setState(() => _error = 'Enter a product name and a price.');
return;
}
Navigator.of(context).pop(
LineItem(
id: widget.item?.id ?? 'manual-${DateTime.now().microsecondsSinceEpoch}',
name: name,
price: roundMoney(price),
quantity: widget.item?.quantity,
unitPrice: widget.item?.unitPrice,
),
);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.item == null ? 'Add item' : 'Edit item',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
controller: _nameController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(labelText: 'Product name'),
autofocus: widget.item == null,
),
const SizedBox(height: 12),
TextField(
controller: _priceController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Price'),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
);
Widget? _itemSubtitle(LineItem item) {
final parts = [
if (item.quantity != null)
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}',
];
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
}
}