Files
receipity/lib/review_screen.dart
T
2026-09-02 12:35:35 +02:00

460 lines
16 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 '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;
@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;
}
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 = widget.imagePath;
return path != null && path.isNotEmpty && File(path).existsSync();
}
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> _openReceipt() async {
final path = widget.imagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (!file.existsSync()) return;
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(file, fit: BoxFit.contain),
),
);
},
);
}
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,
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 receiptPath = widget.imagePath;
final highlighted = _highlightedItemId == null
? null
: _items.cast<LineItem?>().firstWhere(
(item) => item?.id == _highlightedItemId,
orElse: () => null,
);
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(
'Item count or totals do not match the receipt, or some lines need manual review. Check highlighted 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: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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),
),
],
),
),
),
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];
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(
selected: _highlightedItemId == item.id,
selectedTileColor: theme.colorScheme.primaryContainer
.withValues(alpha: 0.35),
leading: item.sourceBounds != null && _hasReceipt
? IconButton(
icon: Icon(
_highlightedItemId == item.id
? Icons.visibility
: Icons.visibility_outlined,
size: 20,
),
tooltip: 'Show on receipt',
onPressed: _saving
? null
: () => _toggleHighlight(item),
)
: item.needsManualReview
? Icon(
Icons.flag_outlined,
color: theme.colorScheme.error,
size: 20,
)
: null,
title: Text(item.name),
subtitle: _itemSubtitle(item),
trailing: DiscountedPrice(item: item),
onTap: _saving ? null : () => _edit(existing: item),
),
);
},
),
),
],
),
),
if (_hasReceipt)
ExpansionTile(
initiallyExpanded: highlighted?.sourceBounds != null,
title: const Text('See receipt'),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: highlighted?.sourceBounds != null
? ReceiptHighlightOverlay(
imagePath: receiptPath!,
bounds: highlighted!.sourceBounds!,
)
: GestureDetector(
onTap: _openReceipt,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(receiptPath!),
fit: BoxFit.contain,
height: 220,
width: double.infinity,
),
),
),
),
],
),
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) {
if (item.quantity == null) return null;
return Text('${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}');
}
}