Files
receipity/lib/shop_log_screen.dart
T
2026-08-31 12:57:17 +02:00

445 lines
13 KiB
Dart

/// Log of saved shopping trips, with a monthly heatmap and product search.
library;
import 'package:flutter/material.dart';
import 'models/shop.dart';
import 'models/supermarket.dart';
import 'services/app_settings.dart';
import 'services/product_repository.dart';
import 'shop_detail_screen.dart';
import 'utils/dates.dart';
import 'utils/money.dart';
import 'utils/shop_calendar.dart';
import 'utils/shop_log_filter.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/shop_heatmap.dart';
import 'widgets/store_tag.dart';
import 'widgets/tag_editor.dart';
class ShopLogScreen extends StatefulWidget {
const ShopLogScreen({super.key, required this.repository});
final ProductRepository repository;
@override
State<ShopLogScreen> createState() => _ShopLogScreenState();
}
class _ShopLogScreenState extends State<ShopLogScreen> {
List<Shop>? _shops;
Map<int, List<String>> _productNames = const {};
Object? _error;
int _loadToken = 0;
final _search = TextEditingController();
bool _editing = false;
bool _searching = false;
DateTime _month = monthStart(DateTime.now());
DateTime? _selectedDay;
String? _tagFilter;
bool get _hasTrips => _shops != null && _shops!.isNotEmpty;
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_search.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
_search.dispose();
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final shops = await widget.repository.getShops();
final productNames = await widget.repository.getShopItemNamesByShop();
if (!mounted || token != _loadToken) return;
setState(() {
_shops = shops;
_productNames = productNames;
_error = null;
if (shops.isEmpty) {
_editing = false;
if (_searching) {
_searching = false;
_search.clear();
}
}
});
} catch (error) {
if (!mounted || token != _loadToken) return;
setState(() => _error = error);
}
}
Future<void> _openShop(Shop shop) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) =>
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
),
);
if (mounted) _load();
}
Future<void> _removeShop(Shop shop) async {
final confirmed = await confirmAction(
context,
title: 'Remove shopping trip?',
message:
'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
);
if (!confirmed || !mounted) return;
setState(() {
_shops = [for (final item in _shops ?? const <Shop>[]) if (item.id != shop.id) item];
_productNames = Map.of(_productNames)..remove(shop.id);
if (_shops!.isEmpty) {
_editing = false;
_searching = false;
_search.clear();
}
});
try {
await widget.repository.deleteShop(shop.id);
} catch (_) {
if (mounted) _load();
}
}
void _selectDay(DateTime? day) {
setState(() => _selectedDay = day);
}
void _goMonth(int delta) {
final next = addMonths(_month, delta);
final current = monthStart(DateTime.now());
if (delta > 0 && !next.isBefore(current) && next != current) return;
setState(() {
_month = next.isAfter(current) ? current : next;
_selectedDay = null;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
final nowMonth = monthStart(DateTime.now());
return Scaffold(
appBar: AppBar(
title: _searching
? TextField(
controller: _search,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search products in trips',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
),
)
: const Text('Shop log'),
actions: [
IconButton(
tooltip: _searching ? 'Close search' : 'Search products',
onPressed: _hasTrips
? () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
}
: null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
if (_hasTrips)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(),
],
),
body: _ShopLogBody(
theme: theme,
stores: stores,
nowMonth: nowMonth,
shops: _shops,
productNames: _productNames,
error: _error,
month: _month,
selectedDay: _selectedDay,
tagFilter: _tagFilter,
query: _search.text,
editing: _editing,
onPreviousMonth: () => _goMonth(-1),
onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
onClearTag: () => setState(() => _tagFilter = null),
onTagTap: (tag) {
setState(() {
_tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase()
? null
: tag;
});
},
onOpen: _openShop,
onRemove: _removeShop,
),
);
}
}
class _ShopLogBody extends StatelessWidget {
const _ShopLogBody({
required this.theme,
required this.stores,
required this.nowMonth,
required this.shops,
required this.productNames,
required this.error,
required this.month,
required this.selectedDay,
required this.tagFilter,
required this.query,
required this.editing,
required this.onPreviousMonth,
required this.onNextMonth,
required this.onSelectDay,
required this.onClearTag,
required this.onTagTap,
required this.onOpen,
required this.onRemove,
});
final ThemeData theme;
final List<Supermarket> stores;
final DateTime nowMonth;
final List<Shop>? shops;
final Map<int, List<String>> productNames;
final Object? error;
final DateTime month;
final DateTime? selectedDay;
final String? tagFilter;
final String query;
final bool editing;
final VoidCallback onPreviousMonth;
final VoidCallback onNextMonth;
final ValueChanged<DateTime?> onSelectDay;
final VoidCallback onClearTag;
final ValueChanged<String> onTagTap;
final ValueChanged<Shop> onOpen;
final ValueChanged<Shop> onRemove;
@override
Widget build(BuildContext context) {
if (error != null && shops == null) {
return Center(
child: Text(
'Could not load the shop log.',
style: theme.textTheme.bodyLarge,
),
);
}
if (shops == null) {
return const Center(child: CircularProgressIndicator());
}
final allShops = shops!;
final counts = tripCountsByDay([
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop.shoppedAt,
]);
final monthShops = [
for (final shop in allShops)
if (isSameMonth(shop.shoppedAt, month)) shop,
];
final monthTotal = monthShops.fold<double>(
0,
(sum, shop) => sum + shop.total,
);
final visible = filterShops(
shops: allShops,
productNames: productNames,
selectedDay: selectedDay,
tag: tagFilter,
query: query,
);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
ShopHeatmap(
month: month,
counts: counts,
monthTotal: monthTotal,
monthTripCount: monthShops.length,
canGoForward: month.isBefore(nowMonth),
selectedDay: selectedDay,
onPreviousMonth: onPreviousMonth,
onNextMonth: onNextMonth,
onSelectDay: onSelectDay,
),
if (selectedDay != null || tagFilter != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (selectedDay != null)
InputChip(
label: Text(formatDate(selectedDay!)),
onDeleted: () => onSelectDay(null),
),
if (tagFilter != null)
InputChip(
label: Text(tagFilter!),
onDeleted: onClearTag,
),
],
),
),
if (allShops.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Scan a receipt or enter a trip to log it.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else if (visible.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
query.trim().isNotEmpty
? 'No trips contain that product.'
: 'No trips match this filter.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
)
else
for (final shop in visible)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
child: _ShopLogTile(
shop: shop,
stores: stores,
editing: editing,
selectedTag: tagFilter,
onOpen: () => onOpen(shop),
onRemove: () => onRemove(shop),
onTagTap: onTagTap,
),
),
],
);
}
}
class _ShopLogTile extends StatelessWidget {
const _ShopLogTile({
required this.shop,
required this.stores,
required this.editing,
required this.onOpen,
required this.onRemove,
required this.onTagTap,
this.selectedTag,
});
final Shop shop;
final List<Supermarket> stores;
final bool editing;
final VoidCallback onOpen;
final VoidCallback onRemove;
final ValueChanged<String> onTagTap;
final String? selectedTag;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final count = shop.itemCount;
final hasName = shop.name != null && shop.name!.isNotEmpty;
final meta = [
formatDate(shop.shoppedAt),
count == 1 ? '1 product' : '$count products',
].join(' · ');
return Material(
color: Colors.transparent,
child: InkWell(
onTap: editing ? null : onOpen,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (editing)
IconButton(
tooltip: 'Remove',
onPressed: onRemove,
icon: const Icon(Icons.delete_outline),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasName)
Text(shop.name!, style: theme.textTheme.titleMedium)
else
StoreTag(name: shop.supermarket, stores: stores),
const SizedBox(height: 4),
if (hasName) ...[
StoreTag(
name: shop.supermarket,
stores: stores,
compact: true,
),
const SizedBox(height: 4),
],
Text(meta, style: theme.textTheme.bodyMedium),
if (shop.tags.isNotEmpty) ...[
const SizedBox(height: 8),
TagChips(
tags: shop.tags,
compact: true,
selected: selectedTag,
onTap: onTagTap,
),
],
],
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
),
],
),
),
),
);
}
}