275 lines
8.5 KiB
Dart
275 lines
8.5 KiB
Dart
/// 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'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|