home added

This commit is contained in:
gitea
2026-08-31 12:57:17 +02:00
parent 92581e6d55
commit 7692809219
18 changed files with 1446 additions and 384 deletions
+250 -154
View File
@@ -13,6 +13,7 @@ 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';
@@ -28,8 +29,10 @@ class ShopLogScreen extends StatefulWidget {
}
class _ShopLogScreenState extends State<ShopLogScreen> {
late Future<({List<Shop> shops, Map<int, List<String>> productNames})>
_future;
List<Shop>? _shops;
Map<int, List<String>> _productNames = const {};
Object? _error;
int _loadToken = 0;
final _search = TextEditingController();
bool _editing = false;
bool _searching = false;
@@ -37,28 +40,45 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
DateTime? _selectedDay;
String? _tagFilter;
bool get _hasTrips => _shops != null && _shops!.isNotEmpty;
@override
void initState() {
super.initState();
_future = _load();
widget.repository.addListener(_load);
_search.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
_search.dispose();
super.dispose();
}
Future<({List<Shop> shops, Map<int, List<String>> productNames})>
_load() async {
final shops = await widget.repository.getShops();
final productNames = await widget.repository.getShopItemNamesByShop();
return (shops: shops, productNames: productNames);
}
void _reload() {
setState(() => _future = _load());
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 {
@@ -68,18 +88,31 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
),
);
if (mounted) _reload();
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.',
message:
'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
);
if (!confirmed || !mounted) return;
await widget.repository.deleteShop(shop.id);
if (mounted) _reload();
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) {
@@ -120,142 +153,205 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
actions: [
IconButton(
tooltip: _searching ? 'Close search' : 'Search products',
onPressed: () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
},
onPressed: _hasTrips
? () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
}
: null,
icon: Icon(_searching ? Icons.close : Icons.search),
),
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
if (_hasTrips)
IconButton(
tooltip: _editing ? 'Done' : 'Edit',
onPressed: () => setState(() => _editing = !_editing),
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
),
const ProfileButton(),
const SettingsButton(),
],
),
body: FutureBuilder(
future: _future,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(
'Could not load the shop log.',
style: theme.textTheme.bodyLarge,
),
);
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final shops = snapshot.data!.shops;
final productNames = snapshot.data!.productNames;
final counts = tripCountsByDay([
for (final shop in shops)
if (isSameMonth(shop.shoppedAt, _month)) shop.shoppedAt,
]);
final monthShops = [
for (final shop in shops)
if (isSameMonth(shop.shoppedAt, _month)) shop,
];
final monthTotal = monthShops.fold<double>(
0,
(sum, shop) => sum + shop.total,
);
final visible = filterShops(
shops: shops,
productNames: productNames,
selectedDay: _selectedDay,
tag: _tagFilter,
query: _search.text,
);
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: () => _goMonth(-1),
onNextMonth: () => _goMonth(1),
onSelectDay: _selectDay,
),
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: () => _selectDay(null),
),
if (_tagFilter != null)
InputChip(
label: Text(_tagFilter!),
onDeleted: () => setState(() => _tagFilter = null),
),
],
),
),
if (shops.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(
_search.text.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: () => _openShop(shop),
onRemove: () => _removeShop(shop),
onTagTap: (tag) {
setState(() {
_tagFilter =
_tagFilter?.toLowerCase() == tag.toLowerCase()
? null
: tag;
});
},
),
),
],
);
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,
@@ -295,6 +391,12 @@ class _ShopLogTile extends StatelessWidget {
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,
@@ -326,19 +428,13 @@ class _ShopLogTile extends StatelessWidget {
),
),
const SizedBox(width: 8),
editing
? IconButton(
tooltip: 'Remove',
onPressed: onRemove,
icon: const Icon(Icons.delete_outline),
)
: Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
),
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
formatEuro(shop.total),
style: theme.textTheme.titleMedium,
),
),
],
),
),