parser logic added
This commit is contained in:
+46
-117
@@ -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(' · '));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user