home added
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
/// 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user