453 lines
14 KiB
Dart
453 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();
|
|
final lastTripStores = await widget.repository.getLastTripSupermarkets();
|
|
if (!mounted || token != _loadToken) return;
|
|
setState(() {
|
|
_overview = overview;
|
|
_stores = sortStoresByLastTrip(stores, lastTripStores);
|
|
_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(
|
|
key: ValueKey(
|
|
stores.isEmpty ? 'bonus-empty' : 'bonus-${stores.first.name}',
|
|
),
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|