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

449 lines
14 KiB
Dart

/// Dashboard of catalog, trips, lists, and this month's spend.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'models/app_overview.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 'widgets/profile_button.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({
super.key,
required this.repository,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
});
final ProductRepository repository;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
AppOverview? _overview;
List<Supermarket> _stores = const [];
Object? _error;
int _loadToken = 0;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
super.initState();
widget.repository.addListener(_load);
_load();
}
@override
void dispose() {
widget.repository.removeListener(_load);
super.dispose();
}
Future<void> _load() async {
final token = ++_loadToken;
try {
final overview = await widget.repository.getOverview();
final stores = await widget.repository.getSupermarkets();
if (!mounted || token != _loadToken) return;
setState(() {
_overview = overview;
_stores = stores;
_error = null;
});
} 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> _openBonusCard(Supermarket store) async {
final path = store.bonusImagePath;
if (path != null && path.isNotEmpty && File(path).existsSync()) {
await _viewBonusCard(path);
return;
}
await _pickBonusCard(store);
}
Future<void> _viewBonusCard(String path) async {
await showDialog<void>(
context: context,
builder: (context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: InteractiveViewer(
child: Image.file(File(path), fit: BoxFit.contain),
),
);
},
);
}
Future<void> _pickBonusCard(Supermarket store) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Take photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Pick from gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
);
},
);
if (source == null || store.id == null) return;
final file = await _picker.pickImage(source: source, imageQuality: 85);
if (file == null) return;
await widget.repository.setSupermarketBonusCard(store.id!, file.path);
if (mounted) _load();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
actions: const [ProfileButton(), SettingsButton()],
),
body: _error != null && _overview == null
? Center(
child: Text(
'Could not load an overview.',
style: theme.textTheme.bodyLarge,
),
)
: _overview == null
? const Center(child: CircularProgressIndicator())
: _HomeBody(
overview: _overview!,
stores: _stores,
onOpenProducts: widget.onOpenProducts,
onOpenLists: widget.onOpenLists,
onOpenShopLog: widget.onOpenShopLog,
onOpenAdd: widget.onOpenAdd,
onOpenShop: _openShop,
onOpenBonusCard: _openBonusCard,
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.overview,
required this.stores,
required this.onOpenProducts,
required this.onOpenLists,
required this.onOpenShopLog,
required this.onOpenAdd,
required this.onOpenShop,
required this.onOpenBonusCard,
});
final AppOverview overview;
final List<Supermarket> stores;
final VoidCallback onOpenProducts;
final VoidCallback onOpenLists;
final VoidCallback onOpenShopLog;
final VoidCallback onOpenAdd;
final ValueChanged<Shop> onOpenShop;
final ValueChanged<Supermarket> onOpenBonusCard;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settingsStores =
SettingsScope.maybeOf(context)?.supermarkets ?? stores;
final empty =
overview.productCount == 0 &&
overview.tripCount == 0 &&
overview.listCount == 0;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text('Overview', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
if (empty)
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
'Nothing logged yet.',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Scan a receipt or add a trip to see products, lists, and spending here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onOpenAdd,
icon: const Icon(Icons.add),
label: const Text('Add a trip'),
),
],
),
),
)
else ...[
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.receipt_long_outlined,
label: formatMonthYear(DateTime.now()),
value: formatEuro(overview.monthTotal),
detail: overview.monthTripCount == 0
? 'No trips yet'
: overview.monthTripCount == 1
? '1 trip'
: '${overview.monthTripCount} trips',
onTap: onOpenShopLog,
),
),
const SizedBox(width: 8),
Expanded(
child: _StatCard(
icon: Icons.inventory_2_outlined,
label: 'Products',
value: '${overview.productCount}',
detail: overview.favouriteCount == 0
? 'No favourites'
: overview.favouriteCount == 1
? '1 favourite'
: '${overview.favouriteCount} favourites',
onTap: onOpenProducts,
),
),
],
),
const SizedBox(height: 8),
_StatCard(
icon: Icons.checklist_outlined,
label: 'Shopping lists',
value: '${overview.listCount}',
detail: overview.listCount == 1 ? '1 list' : 'Open your lists',
onTap: onOpenLists,
),
],
if (stores.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Bonus cards', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 148,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: stores.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final store = stores[index];
return _BonusCardTile(
store: store,
stores: settingsStores,
onTap: () => onOpenBonusCard(store),
);
},
),
),
],
if (overview.recentShops.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Recent trips', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final shop in overview.recentShops)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: shop.name == null || shop.name!.isEmpty
? StoreTag(name: shop.supermarket, stores: settingsStores)
: Text(shop.name!),
subtitle: Text(
[
formatDate(shop.shoppedAt),
formatEuro(shop.total),
].join(' · '),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => onOpenShop(shop),
),
),
],
],
);
}
}
class _BonusCardTile extends StatelessWidget {
const _BonusCardTile({
required this.store,
required this.stores,
required this.onTap,
});
final Supermarket store;
final List<Supermarket> stores;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final path = store.bonusImagePath;
final hasCard =
path != null && path.isNotEmpty && File(path).existsSync();
return SizedBox(
width: 220,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: !hasCard
? ColoredBox(
color: store.color.withValues(alpha: 0.18),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.card_membership_outlined,
color: store.color,
size: 32,
),
const SizedBox(height: 8),
Text(
'Add bonus card',
style: theme.textTheme.bodySmall,
),
],
),
),
)
: Image.file(
File(path),
fit: BoxFit.cover,
width: double.infinity,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: Row(
children: [
StoreTag(name: store.name, stores: stores, compact: true),
const Spacer(),
Icon(
hasCard
? Icons.zoom_in
: Icons.camera_alt_outlined,
size: 16,
color: scheme.onSurfaceVariant,
),
],
),
),
],
),
),
),
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.detail,
required this.onTap,
});
final IconData icon;
final String label;
final String value;
final String detail;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: scheme.primary),
const SizedBox(height: 12),
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.titleLarge),
const SizedBox(height: 2),
Text(detail, style: theme.textTheme.bodyMedium),
],
),
),
),
);
}
}