279 lines
8.1 KiB
Dart
279 lines
8.1 KiB
Dart
/// Dashboard of catalog, trips, lists, and this month's spend.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'models/app_overview.dart';
|
|
import 'models/shop.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;
|
|
Object? _error;
|
|
int _loadToken = 0;
|
|
|
|
@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();
|
|
if (!mounted || token != _loadToken) return;
|
|
setState(() {
|
|
_overview = overview;
|
|
_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();
|
|
}
|
|
|
|
@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!,
|
|
onOpenProducts: widget.onOpenProducts,
|
|
onOpenLists: widget.onOpenLists,
|
|
onOpenShopLog: widget.onOpenShopLog,
|
|
onOpenAdd: widget.onOpenAdd,
|
|
onOpenShop: _openShop,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HomeBody extends StatelessWidget {
|
|
const _HomeBody({
|
|
required this.overview,
|
|
required this.onOpenProducts,
|
|
required this.onOpenLists,
|
|
required this.onOpenShopLog,
|
|
required this.onOpenAdd,
|
|
required this.onOpenShop,
|
|
});
|
|
|
|
final AppOverview overview;
|
|
final VoidCallback onOpenProducts;
|
|
final VoidCallback onOpenLists;
|
|
final VoidCallback onOpenShopLog;
|
|
final VoidCallback onOpenAdd;
|
|
final ValueChanged<Shop> onOpenShop;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
|
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 (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: stores)
|
|
: Text(shop.name!),
|
|
subtitle: Text(
|
|
[
|
|
formatDate(shop.shoppedAt),
|
|
formatEuro(shop.total),
|
|
].join(' · '),
|
|
),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => onOpenShop(shop),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|