Files
receipity/lib/review_screen.dart
T
2026-09-03 15:09:17 +02:00

639 lines
22 KiB
Dart
Raw 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 'dart:io';
import 'package:flutter/material.dart';
import 'models/line_item.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'services/receipt_capture.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/supermarkets.dart';
import 'widgets/discounted_price.dart';
import 'widgets/item_edit_sheet.dart';
import 'widgets/receipt_highlight_overlay.dart';
import 'widgets/store_tag.dart';
import 'widgets/tag_editor.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,
this.ocrImageWidth,
this.ocrImageHeight,
});
final List<LineItem> items;
final String rawText;
final ProductRepository repository;
final String? imagePath;
final List<String> supermarketNames;
final bool needsReview;
final String? detectedStore;
final double? ocrImageWidth;
final double? ocrImageHeight;
@override
State<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
late List<LineItem> _items;
late DateTime _shoppedAt;
String? _supermarket;
String? _customStore;
bool _saving = false;
final _tripName = TextEditingController();
List<String> _tags = [];
List<String> _tagSuggestions = [];
String? _highlightedItemId;
bool _showTripExtras = false;
late String _rawText;
String? _imagePath;
late bool _needsReview;
bool _addingReceipt = false;
@override
void initState() {
super.initState();
_items = List<LineItem>.from(widget.items);
_shoppedAt = DateTime.now();
_rawText = widget.rawText;
_imagePath = widget.imagePath;
_needsReview = widget.needsReview;
final detected = detectSupermarket(
widget.rawText,
names: widget.supermarketNames,
);
if (detected != null) {
_supermarket = detected;
} else if (widget.detectedStore != null) {
_supermarket = widget.detectedStore;
}
widget.repository
.getUsedShopTags()
.then((tags) {
if (mounted) setState(() => _tagSuggestions = tags);
})
.catchError((_) {});
}
@override
void dispose() {
_tripName.dispose();
super.dispose();
}
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;
}
bool get _hasReceipt {
final path = _imagePath;
return path != null && path.isNotEmpty && File(path).existsSync();
}
Future<void> _addReceipt() async {
if (_saving || _addingReceipt) return;
setState(() => _addingReceipt = true);
try {
final captured = await captureAndParseReceipt(context);
if (captured == null || !mounted) return;
final keepExistingPhoto = _hasReceipt;
final merged = [
for (final item in captured.items)
keepExistingPhoto
? item.copyWith(clearSourceBounds: true)
: item,
];
setState(() {
_items.addAll(merged);
_needsReview = _needsReview || captured.needsReview;
if (_rawText.isEmpty) {
_rawText = captured.rawText;
} else if (captured.rawText.isNotEmpty) {
_rawText = '$_rawText\n\n---\n\n${captured.rawText}';
}
if (!keepExistingPhoto && captured.imagePath != null) {
_imagePath = captured.imagePath;
}
if (_supermarket == null && captured.detectedStore != null) {
_supermarket = captured.detectedStore;
}
_highlightedItemId = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
merged.length == 1
? 'Added 1 item from receipt.'
: 'Added ${merged.length} items from receipt.',
),
),
);
} finally {
if (mounted) setState(() => _addingReceipt = false);
}
}
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.copyWith(
sourceBounds: existing.sourceBounds,
needsManualReview: false,
);
}
}
_highlightedItemId = null;
});
}
void _toggleHighlight(LineItem item) {
if (item.sourceBounds == null || !_hasReceipt) return;
setState(() {
_highlightedItemId = _highlightedItemId == item.id ? null : item.id;
});
}
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> _openReceiptPanel() async {
final path = _imagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (!file.existsSync()) return;
final highlighted = _highlightedItemId == null
? null
: _items.cast<LineItem?>().firstWhere(
(item) => item?.id == _highlightedItemId,
orElse: () => null,
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Receipt', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
if (highlighted?.sourceBounds != null)
ReceiptHighlightOverlay(
imagePath: path,
bounds: highlighted!.sourceBounds!,
maxHeight: 360,
)
else
GestureDetector(
onTap: () async {
await showDialog<void>(
context: context,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(file, fit: BoxFit.contain),
),
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(file, fit: BoxFit.contain, height: 360),
),
),
],
),
),
);
},
);
}
Future<void> _openOcrPanel() async {
if (_rawText.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Raw OCR text', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.55,
),
child: SingleChildScrollView(
child: SelectableText(_rawText),
),
),
],
),
),
);
},
);
}
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: _imagePath,
name: _tripName.text,
tags: _tags,
);
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!);
}
final flaggedCount =
_items.where((item) => item.needsManualReview).length;
final reviewMessage = flaggedCount > 0
? 'Some lines could not be parsed confidently — '
'${flaggedCount == 1 ? '1 item is' : '$flaggedCount items are'} '
'marked below. Check them against the receipt before saving.'
: 'Item count or totals do not match the receipt. '
'Check the items below before saving.';
return Scaffold(
appBar: AppBar(
title: Text(
_rawText.isEmpty && _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 (_needsReview) ...[
Material(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
reviewMessage,
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: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
_showTripExtras
? Icons.expand_less
: Icons.edit_note_outlined,
),
tooltip: _showTripExtras
? 'Hide trip name & tags'
: 'Trip name & tags',
onPressed: _saving
? null
: () => setState(
() => _showTripExtras = !_showTripExtras,
),
),
const Icon(Icons.calendar_today_outlined),
],
),
title: Text(formatDate(_shoppedAt)),
subtitle: const Text('Shopping date'),
onTap: _saving ? null : _pickDate,
),
if (_showTripExtras) ...[
const SizedBox(height: 8),
TextField(
controller: _tripName,
enabled: !_saving,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Trip name',
hintText: 'Optional, e.g. Weekly shop',
),
),
const SizedBox(height: 12),
TagEditor(
tags: _tags,
suggestions: _tagSuggestions,
enabled: !_saving,
onChanged: (tags) => setState(() => _tags = tags),
),
const SizedBox(height: 8),
],
],
),
),
Expanded(
child: CustomScrollView(
slivers: [
if (_items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'No line items yet.\nAdd products for this trip.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverList.separated(
itemCount: _items.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = _items[index];
final flagged = item.needsManualReview;
final onReceipt = _highlightedItemId == item.id;
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(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: flagged
? BorderSide(
color: theme.colorScheme.error
.withValues(alpha: 0.55),
)
: BorderSide.none,
),
tileColor: flagged
? theme.colorScheme.errorContainer
.withValues(alpha: 0.35)
: onReceipt
? theme.colorScheme.primaryContainer
.withValues(alpha: 0.35)
: null,
leading: item.sourceBounds != null && _hasReceipt
? IconButton(
icon: Icon(
onReceipt
? Icons.visibility
: Icons.visibility_outlined,
size: 20,
),
tooltip: 'Show on receipt',
onPressed: _saving
? null
: () => _toggleHighlight(item),
)
: flagged
? Icon(
Icons.flag_outlined,
color: theme.colorScheme.error,
size: 20,
)
: null,
title: Text(item.name),
subtitle: _itemSubtitle(item, flagged: flagged),
trailing: DiscountedPrice(item: item),
onTap: _saving ? null : () => _edit(existing: item),
),
);
},
),
),
],
),
),
if (_hasReceipt || _rawText.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(
children: [
if (_hasReceipt)
Expanded(
child: OutlinedButton.icon(
onPressed: _saving || _addingReceipt
? null
: _openReceiptPanel,
icon: const Icon(Icons.receipt_long_outlined, size: 18),
label: const Text('Receipt'),
),
),
if (_hasReceipt && _rawText.isNotEmpty)
const SizedBox(width: 8),
if (_rawText.isNotEmpty)
Expanded(
child: OutlinedButton.icon(
onPressed: _saving || _addingReceipt
? null
: _openOcrPanel,
icon: const Icon(Icons.text_snippet_outlined, size: 18),
label: const Text('OCR text'),
),
),
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _saving || _addingReceipt
? null
: () => _edit(),
icon: const Icon(Icons.add),
label: const Text('Add item'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _saving || _addingReceipt
? null
: _addReceipt,
icon: _addingReceipt
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.receipt_long_outlined),
label: const Text('Add receipt'),
),
),
],
),
const SizedBox(height: 8),
FilledButton(
onPressed: _saving || _addingReceipt ? null : _confirm,
child: Text(
_saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
),
),
],
),
),
),
],
),
);
}
Widget? _itemSubtitle(LineItem item, {bool flagged = false}) {
final parts = <String>[];
if (flagged) parts.add('Needs review');
if (item.quantity != null) {
parts.add('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
}
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
}
}