385 lines
12 KiB
Dart
385 lines
12 KiB
Dart
/// Editable list of parsed receipt lines before they are saved as a shop trip.
|
||
library;
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import 'models/line_item.dart';
|
||
import 'services/app_settings.dart';
|
||
import 'services/product_repository.dart';
|
||
import 'utils/dates.dart';
|
||
import 'utils/money.dart';
|
||
import 'utils/supermarkets.dart';
|
||
import 'widgets/store_tag.dart';
|
||
|
||
class ReviewScreen extends StatefulWidget {
|
||
const ReviewScreen({
|
||
super.key,
|
||
required this.items,
|
||
required this.rawText,
|
||
required this.repository,
|
||
this.imagePath,
|
||
this.supermarketNames = kSupermarkets,
|
||
});
|
||
|
||
final List<LineItem> items;
|
||
final String rawText;
|
||
final ProductRepository repository;
|
||
final String? imagePath;
|
||
final List<String> supermarketNames;
|
||
|
||
@override
|
||
State<ReviewScreen> createState() => _ReviewScreenState();
|
||
}
|
||
|
||
class _ReviewScreenState extends State<ReviewScreen> {
|
||
late List<LineItem> _items;
|
||
late DateTime _shoppedAt;
|
||
String? _supermarket;
|
||
String? _customStore;
|
||
bool _saving = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_items = List<LineItem>.from(widget.items);
|
||
_shoppedAt = DateTime.now();
|
||
final detected = detectSupermarket(
|
||
widget.rawText,
|
||
names: widget.supermarketNames,
|
||
);
|
||
if (detected != null) {
|
||
_supermarket = detected;
|
||
}
|
||
}
|
||
|
||
String? get _resolvedSupermarket {
|
||
if (_supermarket == null) return null;
|
||
if (_supermarket == kOtherSupermarket) {
|
||
final custom = _customStore?.trim();
|
||
if (custom == null || custom.isEmpty) return null;
|
||
return custom;
|
||
}
|
||
return _supermarket;
|
||
}
|
||
|
||
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> _pickDate() async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _shoppedAt,
|
||
firstDate: DateTime(2018),
|
||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||
);
|
||
if (picked == null || !mounted) return;
|
||
setState(() => _shoppedAt = picked);
|
||
}
|
||
|
||
Future<void> _confirm() async {
|
||
final supermarket = _resolvedSupermarket;
|
||
if (supermarket == null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Select a supermarket.')),
|
||
);
|
||
return;
|
||
}
|
||
if (_items.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Add at least one product.')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
setState(() => _saving = true);
|
||
try {
|
||
await widget.repository.saveShop(
|
||
supermarket: supermarket,
|
||
shoppedAt: _shoppedAt,
|
||
items: _items,
|
||
receiptImagePath: widget.imagePath,
|
||
);
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop(true);
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
setState(() => _saving = false);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Could not save this shopping trip.')),
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final total = _items.fold<double>(0, (sum, item) => sum + item.price);
|
||
final settings = SettingsScope.maybeOf(context);
|
||
final stores = settings?.supermarkets ?? kDefaultSupermarkets;
|
||
final names = [
|
||
...{...widget.supermarketNames, ...stores.map((s) => s.name)},
|
||
];
|
||
if (_supermarket != null && !names.contains(_supermarket)) {
|
||
names.add(_supermarket!);
|
||
}
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Review items')),
|
||
body: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
DropdownButtonFormField<String>(
|
||
key: ValueKey(_supermarket),
|
||
initialValue: _supermarket,
|
||
hint: const Text('Select supermarket'),
|
||
decoration: const InputDecoration(
|
||
labelText: 'Supermarket',
|
||
),
|
||
items: [
|
||
for (final store in names)
|
||
DropdownMenuItem(
|
||
value: store,
|
||
child: StoreTag(
|
||
name: store,
|
||
stores: stores,
|
||
compact: true,
|
||
),
|
||
),
|
||
],
|
||
onChanged: _saving
|
||
? null
|
||
: (value) => setState(() => _supermarket = value),
|
||
),
|
||
if (_supermarket == kOtherSupermarket) ...[
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
enabled: !_saving,
|
||
textCapitalization: TextCapitalization.words,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Store name',
|
||
),
|
||
onChanged: (value) => _customStore = value,
|
||
),
|
||
],
|
||
const SizedBox(height: 8),
|
||
ListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
leading: const Icon(Icons.calendar_today_outlined),
|
||
title: Text(formatDate(_shoppedAt)),
|
||
subtitle: const Text('Shopping date'),
|
||
onTap: _saving ? null : _pickDate,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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…'
|
||
: 'Save trip · ${formatEuro(total)}',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|