scan to list working fine
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user