Files
receipity/lib/shop_detail_screen.dart
T
2026-08-31 16:51:31 +02:00

445 lines
13 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 '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;
@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> _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,
);
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),
),
],
),
floatingActionButton: _editing
? FloatingActionButton.extended(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text('Add product'),
)
: 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,
),
),
],
);
},
),
);
}
}