Files
receipity/lib/review_screen.dart
2026-08-30 12:24:29 +02:00

314 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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(' · '));
}
}