scan to list working fine

This commit is contained in:
davmar
2026-08-29 11:23:24 +02:00
parent 2d8fc46a30
commit 2a5a922aab
13 changed files with 1233 additions and 8 deletions
+274
View File
@@ -0,0 +1,274 @@
/// Editable list of parsed receipt lines before they are saved as products.
library;
import 'package:flutter/material.dart';
import 'models/line_item.dart';
import 'products_screen.dart';
import 'services/product_repository.dart';
import 'utils/money.dart';
class ReviewScreen extends StatefulWidget {
const ReviewScreen({
super.key,
required this.items,
required this.rawText,
required this.repository,
});
final List<LineItem> items;
final String rawText;
final ProductRepository repository;
@override
State<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
late List<LineItem> _items;
bool _saving = false;
@override
void initState() {
super.initState();
_items = List<LineItem>.from(widget.items);
}
Future<void> _edit({LineItem? existing}) async {
final result = await showModalBottomSheet<LineItem>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _ItemEditSheet(item: existing),
);
if (result == null || !mounted) return;
setState(() {
if (existing == null) {
_items.add(result);
} else {
final index = _items.indexWhere((item) => item.id == existing.id);
if (index >= 0) {
_items[index] = result;
}
}
});
}
Future<void> _confirm() async {
setState(() => _saving = true);
try {
await widget.repository.upsertItems(_items);
if (!mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(
builder: (_) => ProductsScreen(
repository: widget.repository,
savedCount: _items.length,
),
),
(route) => route.isFirst,
);
} catch (_) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not save products.')),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Review items')),
body: Column(
children: [
Expanded(
child: _items.isEmpty
? Center(
child: Text(
'No line items found.\nAdd anything the parser missed.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
itemCount: _items.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = _items[index];
return Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.delete_outline,
color: theme.colorScheme.onErrorContainer,
),
),
onDismissed: (_) {
setState(() {
_items.removeWhere((row) => row.id == item.id);
});
},
child: ListTile(
title: Text(item.name),
subtitle: item.quantity == null
? null
: Text(
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
),
trailing: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
onTap: _saving ? null : () => _edit(existing: item),
),
);
},
),
),
if (widget.rawText.isNotEmpty)
ExpansionTile(
title: const Text('Raw OCR text'),
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SelectableText(widget.rawText),
),
),
],
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton.icon(
onPressed: _saving ? null : () => _edit(),
icon: const Icon(Icons.add),
label: const Text('Add item'),
),
const SizedBox(height: 8),
FilledButton(
onPressed: _saving ? null : _confirm,
child: Text(
_saving ? 'Saving…' : 'Confirm',
),
),
],
),
),
),
],
),
);
}
}
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'),
),
],
),
);
}
}