314 lines
10 KiB
Dart
314 lines
10 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/item_edit_sheet.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,
|
||
this.needsReview = false,
|
||
this.detectedStore,
|
||
});
|
||
|
||
final List<LineItem> items;
|
||
final String rawText;
|
||
final ProductRepository repository;
|
||
final String? imagePath;
|
||
final List<String> supermarketNames;
|
||
final bool needsReview;
|
||
final String? detectedStore;
|
||
|
||
@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;
|
||
} else if (widget.detectedStore != null) {
|
||
_supermarket = widget.detectedStore;
|
||
}
|
||
}
|
||
|
||
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 showItemEditSheet(context, 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: Text(
|
||
widget.rawText.isEmpty && widget.imagePath == null
|
||
? 'New trip'
|
||
: 'Review items',
|
||
),
|
||
),
|
||
body: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||
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'),
|
||
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 yet.\nAdd products for this trip.',
|
||
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: _itemSubtitle(item),
|
||
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)}',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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(' · '));
|
||
}
|
||
}
|