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

514 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.
/// One shopping trip: receipt photo, supermarket, products and costs.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'models/line_item.dart';
import 'models/shop.dart';
import 'product_detail_screen.dart';
import 'services/product_repository.dart';
import 'services/receipt_capture.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/item_edit_sheet.dart';
import 'widgets/name_dialog.dart';
import 'widgets/tag_editor.dart';
class ShopDetailScreen extends StatefulWidget {
const ShopDetailScreen({
super.key,
required this.repository,
required this.shopId,
});
final ProductRepository repository;
final int shopId;
@override
State<ShopDetailScreen> createState() => _ShopDetailScreenState();
}
class _ShopDetailScreenState extends State<ShopDetailScreen> {
Shop? _shop;
List<ShopItem>? _items;
Object? _error;
bool _editing = false;
List<String> _tagSuggestions = [];
int _loadToken = 0;
bool _addingReceipt = false;
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
widget.repository
.getUsedShopTags()
.then((tags) {
if (mounted) setState(() => _tagSuggestions = tags);
})
.catchError((_) {});
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final shop = await widget.repository.getShop(widget.shopId);
if (shop == null) throw StateError('Shop not found');
final items = await widget.repository.getShopItems(widget.shopId);
if (!mounted || token != _loadToken) return;
setState(() {
_shop = shop;
_items = items;
_error = null;
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _openReceipt(File file) async {
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> _openProduct(int productId) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ProductDetailScreen(
repository: widget.repository,
productId: productId,
),
),
);
}
Future<void> _addItem() async {
final shop = _shop;
if (shop == null) return;
final result = await showItemEditSheet(context);
if (result == null || !mounted) return;
final pendingId = -DateTime.now().microsecondsSinceEpoch;
final pending = ShopItem(
id: pendingId,
shopId: shop.id,
name: result.name,
price: result.price,
quantity: result.quantity,
unitPrice: result.unitPrice,
);
setState(() {
_items = [...?_items, pending];
_shop = Shop(
id: shop.id,
supermarket: shop.supermarket,
shoppedAt: shop.shoppedAt,
total: roundMoney(shop.total + result.price),
itemCount: shop.itemCount + 1,
name: shop.name,
receiptImagePath: shop.receiptImagePath,
tags: shop.tags,
);
});
try {
final saved = await widget.repository.addShopItem(
shopId: shop.id,
item: result,
);
if (!mounted) return;
setState(() {
_items = [
for (final entry in _items ?? const <ShopItem>[])
if (entry.id == pendingId) saved else entry,
];
});
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not add this product.')),
);
_load();
}
}
Future<void> _addFromReceipt() async {
final shop = _shop;
if (shop == null || _addingReceipt) return;
setState(() => _addingReceipt = true);
try {
final captured = await captureAndParseReceipt(context);
if (captured == null || !mounted) return;
var added = 0;
for (final item in captured.items) {
await widget.repository.addShopItem(shopId: shop.id, item: item);
added++;
}
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added == 1
? 'Added 1 item from receipt.'
: 'Added $added items from receipt.',
),
),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not add items from this receipt.')),
);
_load();
} finally {
if (mounted) setState(() => _addingReceipt = false);
}
}
Future<void> _editItem(ShopItem item) async {
final result = await showItemEditSheet(
context,
item: LineItem(
id: '${item.id}',
name: item.name,
price: item.price,
quantity: item.quantity,
unitPrice: item.unitPrice,
),
);
if (result == null || !mounted) return;
await widget.repository.updateShopItem(
id: item.id,
name: result.name,
price: result.price,
quantity: result.quantity,
unitPrice: result.unitPrice,
clearQuantity: result.quantity == null,
);
if (mounted) _load();
}
Future<void> _removeItem(ShopItem item) async {
final confirmed = await confirmAction(
context,
title: 'Remove item?',
message:
'"${item.name}" will be removed from this trip. The product stays in your catalog.',
confirmLabel: 'Remove',
);
if (!confirmed || !mounted) return;
final isLastItem = (_items?.length ?? 0) == 1;
final shop = _shop;
setState(() {
_items = [
for (final entry in _items ?? const <ShopItem>[])
if (entry.id != item.id) entry,
];
if (shop != null) {
_shop = Shop(
id: shop.id,
supermarket: shop.supermarket,
shoppedAt: shop.shoppedAt,
total: roundMoney(shop.total - item.price),
itemCount: shop.itemCount - 1,
name: shop.name,
receiptImagePath: shop.receiptImagePath,
tags: shop.tags,
);
}
});
try {
await widget.repository.deleteShopItem(item.id);
if (!mounted || !isLastItem || shop == null) return;
await _confirmRemoveEmptyTrip(shop);
} catch (_) {
if (mounted) _load();
}
}
Future<void> _confirmRemoveEmptyTrip(Shop shop) async {
final removeTrip = await confirmAction(
context,
title: 'Remove trip?',
message:
'This trip has no products left. Remove it from your shop log? '
'Products from this trip stay in your catalog.',
confirmLabel: 'Remove trip',
);
if (!mounted) return;
if (!removeTrip) return;
try {
await widget.repository.deleteShop(shop.id);
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not remove this trip.')),
);
_load();
}
}
Future<void> _editName(Shop shop) async {
final name = await showNameDialog(
context,
title: 'Trip name',
label: 'Name',
initial: shop.name,
allowEmpty: true,
);
if (name == null || !mounted) return;
await widget.repository.updateShop(
id: shop.id,
name: name,
clearName: name.isEmpty,
);
if (mounted) _load();
}
Future<void> _editDate(Shop shop) async {
final picked = await showDatePicker(
context: context,
initialDate: shop.shoppedAt,
firstDate: DateTime(2018),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked == null || !mounted) return;
await widget.repository.updateShop(
id: shop.id,
shoppedAt: DateTime(picked.year, picked.month, picked.day),
);
if (mounted) _load();
}
Future<void> _setTags(Shop shop, List<String> tags) async {
await widget.repository.updateShop(id: shop.id, tags: tags);
if (mounted) _load();
}
Widget? _itemSubtitle(ShopItem item) {
final parts = [
if (item.quantity != null)
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
if (_editing) formatEuro(item.price),
];
if (parts.isEmpty) return null;
return Text(parts.join(' · '));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Shopping trip'),
actions: [
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
],
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _editing
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: _addingReceipt ? null : _addFromReceipt,
icon: _addingReceipt
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.receipt_long_outlined),
label: const Text('Add receipt'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: _addingReceipt ? null : _addItem,
icon: const Icon(Icons.add),
label: const Text('Add product'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
),
),
],
),
)
: null,
body: Builder(
builder: (context) {
if (_error != null) {
return Center(
child: Text(
'Could not load this trip.',
style: theme.textTheme.bodyLarge,
),
);
}
final shop = _shop;
final items = _items;
if (shop == null || items == null) {
return const Center(child: CircularProgressIndicator());
}
final receipt = shop.receiptImagePath;
final receiptFile = receipt == null ? null : File(receipt);
final hasReceipt = receiptFile != null && receiptFile.existsSync();
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
children: [
if (_editing)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
shop.name?.isNotEmpty == true
? shop.name!
: 'Add a trip name',
),
subtitle: const Text('Optional name for this shop log'),
trailing: const Icon(Icons.edit_outlined),
onTap: () => _editName(shop),
)
else
Text(shop.displayName, style: theme.textTheme.headlineMedium),
if (_editing || (shop.name != null && shop.name!.isNotEmpty)) ...[
const SizedBox(height: 4),
Text(shop.supermarket, style: theme.textTheme.titleMedium),
],
const SizedBox(height: 4),
if (_editing)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(formatDate(shop.shoppedAt)),
subtitle: const Text('Trip date'),
trailing: const Icon(Icons.calendar_today_outlined),
onTap: () => _editDate(shop),
)
else
Text(
formatDate(shop.shoppedAt),
style: theme.textTheme.bodyLarge,
),
if (_editing) ...[
const SizedBox(height: 16),
TagEditor(
tags: shop.tags,
suggestions: _tagSuggestions,
onChanged: (tags) => _setTags(shop, tags),
),
] else if (shop.tags.isNotEmpty) ...[
const SizedBox(height: 12),
TagChips(tags: shop.tags),
],
if (hasReceipt) ...[
const SizedBox(height: 16),
GestureDetector(
onTap: () => _openReceipt(receiptFile),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
receiptFile,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
cacheWidth: 900,
),
),
),
],
const SizedBox(height: 24),
Text('Products', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
if (items.isEmpty)
Text(
'No items on this trip.',
style: theme.textTheme.bodyLarge,
),
for (final item in items)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(item.name),
subtitle: _itemSubtitle(item),
trailing: _editing
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
visualDensity: VisualDensity.compact,
onPressed: () => _editItem(item),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Remove',
visualDensity: VisualDensity.compact,
onPressed: () => _removeItem(item),
icon: const Icon(Icons.delete_outline),
),
],
)
: Text(
formatEuro(item.price),
style: theme.textTheme.titleMedium,
),
onTap: _editing || item.productId == null
? null
: () => _openProduct(item.productId!),
),
const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Total', style: theme.textTheme.titleMedium),
trailing: Text(
formatEuro(shop.total),
style: theme.textTheme.titleLarge,
),
),
],
);
},
),
);
}
}