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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-22
@@ -1,8 +1,9 @@
|
|||||||
/// Bottom navigation: products, lists, add, and shop log.
|
/// Bottom navigation: home, products, lists, add, and shop log.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'home_screen.dart';
|
||||||
import 'products_screen.dart';
|
import 'products_screen.dart';
|
||||||
import 'scan_screen.dart';
|
import 'scan_screen.dart';
|
||||||
import 'services/app_settings.dart';
|
import 'services/app_settings.dart';
|
||||||
@@ -20,11 +21,14 @@ class HomeShell extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeShellState extends State<HomeShell> {
|
class _HomeShellState extends State<HomeShell> {
|
||||||
|
static const _homeIndex = 0;
|
||||||
|
static const _productsIndex = 1;
|
||||||
static const _addIndex = 2;
|
static const _addIndex = 2;
|
||||||
static const _shopLogIndex = 3;
|
static const _listsIndex = 3;
|
||||||
|
static const _shopLogIndex = 4;
|
||||||
static const _fabGap = 56.0;
|
static const _fabGap = 56.0;
|
||||||
|
|
||||||
int _index = _addIndex;
|
int _index = _homeIndex;
|
||||||
int _revision = 0;
|
int _revision = 0;
|
||||||
AppSettings? _settings;
|
AppSettings? _settings;
|
||||||
int _seenGeneration = 0;
|
int _seenGeneration = 0;
|
||||||
@@ -77,15 +81,23 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: switch (_index) {
|
body: switch (_index) {
|
||||||
0 => ProductsScreen(
|
_homeIndex => HomeScreen(
|
||||||
|
key: ValueKey('home-$_revision'),
|
||||||
|
repository: widget.repository,
|
||||||
|
onOpenProducts: () => _select(_productsIndex),
|
||||||
|
onOpenLists: () => _select(_listsIndex),
|
||||||
|
onOpenShopLog: () => _select(_shopLogIndex),
|
||||||
|
onOpenAdd: () => _select(_addIndex),
|
||||||
|
),
|
||||||
|
_productsIndex => ProductsScreen(
|
||||||
key: ValueKey('products-$_revision'),
|
key: ValueKey('products-$_revision'),
|
||||||
repository: widget.repository,
|
repository: widget.repository,
|
||||||
),
|
),
|
||||||
1 => ShoppingListsScreen(
|
_listsIndex => ShoppingListsScreen(
|
||||||
key: ValueKey('lists-$_revision'),
|
key: ValueKey('lists-$_revision'),
|
||||||
repository: widget.repository,
|
repository: widget.repository,
|
||||||
),
|
),
|
||||||
2 => ScanScreen(
|
_addIndex => ScanScreen(
|
||||||
repository: widget.repository,
|
repository: widget.repository,
|
||||||
onTripSaved: _onTripSaved,
|
onTripSaved: _onTripSaved,
|
||||||
),
|
),
|
||||||
@@ -118,20 +130,20 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _NavButton(
|
child: _NavButton(
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.home_outlined,
|
||||||
selectedIcon: Icons.inventory_2,
|
selectedIcon: Icons.home,
|
||||||
label: 'Products',
|
label: 'Home',
|
||||||
selected: _index == 0,
|
selected: _index == _homeIndex,
|
||||||
onTap: () => _select(0),
|
onTap: () => _select(_homeIndex),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _NavButton(
|
child: _NavButton(
|
||||||
icon: Icons.checklist_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
selectedIcon: Icons.checklist,
|
selectedIcon: Icons.inventory_2,
|
||||||
label: 'Lists',
|
label: 'Products',
|
||||||
selected: _index == 1,
|
selected: _index == _productsIndex,
|
||||||
onTap: () => _select(1),
|
onTap: () => _select(_productsIndex),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -139,12 +151,27 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: _fabGap),
|
const SizedBox(width: _fabGap),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _NavButton(
|
child: Row(
|
||||||
icon: Icons.receipt_long_outlined,
|
children: [
|
||||||
selectedIcon: Icons.receipt_long,
|
Expanded(
|
||||||
label: 'Shop log',
|
child: _NavButton(
|
||||||
selected: _index == _shopLogIndex,
|
icon: Icons.checklist_outlined,
|
||||||
onTap: () => _select(_shopLogIndex),
|
selectedIcon: Icons.checklist,
|
||||||
|
label: 'Lists',
|
||||||
|
selected: _index == _listsIndex,
|
||||||
|
onTap: () => _select(_listsIndex),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _NavButton(
|
||||||
|
icon: Icons.receipt_long_outlined,
|
||||||
|
selectedIcon: Icons.receipt_long,
|
||||||
|
label: 'Shop log',
|
||||||
|
selected: _index == _shopLogIndex,
|
||||||
|
onTap: () => _select(_shopLogIndex),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/// Counts and recent trips shown on the home dashboard.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'shop.dart';
|
||||||
|
|
||||||
|
class AppOverview {
|
||||||
|
const AppOverview({
|
||||||
|
required this.productCount,
|
||||||
|
required this.favouriteCount,
|
||||||
|
required this.tripCount,
|
||||||
|
required this.monthTripCount,
|
||||||
|
required this.monthTotal,
|
||||||
|
required this.listCount,
|
||||||
|
required this.recentShops,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int productCount;
|
||||||
|
final int favouriteCount;
|
||||||
|
final int tripCount;
|
||||||
|
final int monthTripCount;
|
||||||
|
final double monthTotal;
|
||||||
|
final int listCount;
|
||||||
|
final List<Shop> recentShops;
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ class ShopItem {
|
|||||||
const ShopItem({
|
const ShopItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.shopId,
|
required this.shopId,
|
||||||
required this.productId,
|
this.productId,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.price,
|
required this.price,
|
||||||
this.quantity,
|
this.quantity,
|
||||||
@@ -68,7 +68,7 @@ class ShopItem {
|
|||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
final int shopId;
|
final int shopId;
|
||||||
final int productId;
|
final int? productId;
|
||||||
final String name;
|
final String name;
|
||||||
final double price;
|
final double price;
|
||||||
final int? quantity;
|
final int? quantity;
|
||||||
@@ -78,7 +78,7 @@ class ShopItem {
|
|||||||
return ShopItem(
|
return ShopItem(
|
||||||
id: map['id']! as int,
|
id: map['id']! as int,
|
||||||
shopId: map['shop_id']! as int,
|
shopId: map['shop_id']! as int,
|
||||||
productId: map['product_id']! as int,
|
productId: map['product_id'] as int?,
|
||||||
name: map['name']! as String,
|
name: map['name']! as String,
|
||||||
price: (map['price'] as num).toDouble(),
|
price: (map['price'] as num).toDouble(),
|
||||||
quantity: map['quantity'] as int?,
|
quantity: map['quantity'] as int?,
|
||||||
|
|||||||
+262
-110
@@ -4,12 +4,15 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'models/product.dart';
|
import 'models/product.dart';
|
||||||
|
import 'models/supermarket.dart';
|
||||||
import 'product_detail_screen.dart';
|
import 'product_detail_screen.dart';
|
||||||
import 'services/app_settings.dart';
|
import 'services/app_settings.dart';
|
||||||
import 'services/product_repository.dart';
|
import 'services/product_repository.dart';
|
||||||
import 'utils/fuzzy.dart';
|
import 'utils/fuzzy.dart';
|
||||||
import 'utils/money.dart';
|
import 'utils/money.dart';
|
||||||
|
import 'widgets/confirm_dialog.dart';
|
||||||
import 'widgets/product_image.dart';
|
import 'widgets/product_image.dart';
|
||||||
|
import 'widgets/profile_button.dart';
|
||||||
import 'widgets/settings_button.dart';
|
import 'widgets/settings_button.dart';
|
||||||
import 'widgets/store_tag.dart';
|
import 'widgets/store_tag.dart';
|
||||||
|
|
||||||
@@ -26,31 +29,52 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
ProductSort _sort = ProductSort.preferred;
|
ProductSort _sort = ProductSort.preferred;
|
||||||
bool _favouritesOnly = false;
|
bool _favouritesOnly = false;
|
||||||
bool _searching = false;
|
bool _searching = false;
|
||||||
|
bool _editing = false;
|
||||||
final TextEditingController _search = TextEditingController();
|
final TextEditingController _search = TextEditingController();
|
||||||
late Future<List<Product>> _future;
|
List<Product>? _products;
|
||||||
|
Object? _error;
|
||||||
|
int _loadToken = 0;
|
||||||
|
|
||||||
|
bool get _hasProducts => _products != null && _products!.isNotEmpty;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = _load();
|
widget.repository.addListener(_load);
|
||||||
_search.addListener(() => setState(() {}));
|
_search.addListener(() => setState(() {}));
|
||||||
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.repository.removeListener(_load);
|
||||||
_search.dispose();
|
_search.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Product>> _load() {
|
Future<void> _load() async {
|
||||||
return widget.repository.getAll(
|
final token = ++_loadToken;
|
||||||
sort: _sort,
|
try {
|
||||||
favouritesOnly: _favouritesOnly,
|
final products = await widget.repository.getAll(
|
||||||
);
|
sort: _sort,
|
||||||
}
|
favouritesOnly: _favouritesOnly,
|
||||||
|
);
|
||||||
void _reload() {
|
if (!mounted || token != _loadToken) return;
|
||||||
setState(() => _future = _load());
|
setState(() {
|
||||||
|
_products = products;
|
||||||
|
_error = null;
|
||||||
|
if (products.isEmpty) {
|
||||||
|
_editing = false;
|
||||||
|
if (_searching) {
|
||||||
|
_searching = false;
|
||||||
|
_search.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted || token != _loadToken) return;
|
||||||
|
setState(() => _error = error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Product> _filter(List<Product> products) {
|
List<Product> _filter(List<Product> products) {
|
||||||
@@ -82,13 +106,88 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (mounted) _reload();
|
if (mounted) _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _removeProduct(Product product) async {
|
||||||
|
final confirmed = await confirmAction(
|
||||||
|
context,
|
||||||
|
title: 'Delete product?',
|
||||||
|
message:
|
||||||
|
'"${product.name}" will be removed from your catalog. Trip history keeps the purchase line.',
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_products = [
|
||||||
|
for (final item in _products ?? const <Product>[])
|
||||||
|
if (item.id != product.id) item,
|
||||||
|
];
|
||||||
|
if (_products!.isEmpty) {
|
||||||
|
_editing = false;
|
||||||
|
_searching = false;
|
||||||
|
_search.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.repository.deleteProduct(product.id);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _removeAllProducts() async {
|
||||||
|
final confirmed = await confirmAction(
|
||||||
|
context,
|
||||||
|
title: 'Delete all products?',
|
||||||
|
message:
|
||||||
|
'This removes every product from your catalog. Shopping trips stay in the shop log.',
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_products = const [];
|
||||||
|
_editing = false;
|
||||||
|
_searching = false;
|
||||||
|
_search.clear();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.repository.deleteAllProducts();
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFavourite(Product product) async {
|
||||||
|
final next = !product.favourite;
|
||||||
|
setState(() {
|
||||||
|
_products = [
|
||||||
|
for (final item in _products ?? const <Product>[])
|
||||||
|
if (item.id == product.id)
|
||||||
|
item.copyWith(favourite: next)
|
||||||
|
else
|
||||||
|
item,
|
||||||
|
];
|
||||||
|
if (_favouritesOnly && !next) {
|
||||||
|
_products = [
|
||||||
|
for (final item in _products!)
|
||||||
|
if (item.id != product.id) item,
|
||||||
|
];
|
||||||
|
if (_products!.isEmpty) {
|
||||||
|
_editing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.repository.setFavourite(product.id, next);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
||||||
|
final products = _products == null ? const <Product>[] : _filter(_products!);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -106,17 +205,38 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
)
|
)
|
||||||
: const Text('Products'),
|
: const Text('Products'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
if (!_editing)
|
||||||
tooltip: _searching ? 'Close search' : 'Search',
|
IconButton(
|
||||||
onPressed: () {
|
tooltip: _searching ? 'Close search' : 'Search',
|
||||||
setState(() {
|
onPressed: _hasProducts
|
||||||
_searching = !_searching;
|
? () {
|
||||||
if (!_searching) _search.clear();
|
setState(() {
|
||||||
});
|
_searching = !_searching;
|
||||||
},
|
if (!_searching) _search.clear();
|
||||||
icon: Icon(_searching ? Icons.close : Icons.search),
|
});
|
||||||
),
|
}
|
||||||
const SettingsButton(),
|
: null,
|
||||||
|
icon: Icon(_searching ? Icons.close : Icons.search),
|
||||||
|
),
|
||||||
|
if (_hasProducts)
|
||||||
|
IconButton(
|
||||||
|
tooltip: _editing ? 'Done' : 'Edit',
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_editing = !_editing;
|
||||||
|
if (_editing) {
|
||||||
|
_searching = false;
|
||||||
|
_search.clear();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||||
|
),
|
||||||
|
if (_editing)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Delete all products',
|
||||||
|
onPressed: _removeAllProducts,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
if (!_editing) ...const [ProfileButton(), SettingsButton()],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
@@ -141,8 +261,8 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
onSelectionChanged: (selected) {
|
onSelectionChanged: (selected) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sort = selected.first;
|
_sort = selected.first;
|
||||||
_future = _load();
|
|
||||||
});
|
});
|
||||||
|
_load();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -153,8 +273,8 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_favouritesOnly = !_favouritesOnly;
|
_favouritesOnly = !_favouritesOnly;
|
||||||
_future = _load();
|
|
||||||
});
|
});
|
||||||
|
_load();
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_favouritesOnly ? Icons.star : Icons.star_outline,
|
_favouritesOnly ? Icons.star : Icons.star_outline,
|
||||||
@@ -163,93 +283,125 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(child: _buildList(theme, stores, products)),
|
||||||
child: FutureBuilder<List<Product>>(
|
|
||||||
future: _future,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'Could not load products.',
|
|
||||||
style: theme.textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!snapshot.hasData) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
final products = _filter(snapshot.data!);
|
|
||||||
if (snapshot.data!.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
_favouritesOnly
|
|
||||||
? 'Star products to keep them as favourites.'
|
|
||||||
: 'Confirmed receipt items will show up here.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (products.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No products match this search.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.separated(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 16),
|
|
||||||
itemCount: products.length,
|
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final product = products[index];
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 0,
|
|
||||||
),
|
|
||||||
leading: ProductImage(
|
|
||||||
name: product.name,
|
|
||||||
path: product.imagePath,
|
|
||||||
size: 36,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
product.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: product.supermarket == null
|
|
||||||
? null
|
|
||||||
: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 2),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: StoreTag(
|
|
||||||
name: product.supermarket,
|
|
||||||
stores: stores,
|
|
||||||
compact: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Text(
|
|
||||||
formatEuro(product.lastPrice),
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
onTap: () => _openProduct(product),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildList(
|
||||||
|
ThemeData theme,
|
||||||
|
List<Supermarket> stores,
|
||||||
|
List<Product> products,
|
||||||
|
) {
|
||||||
|
if (_error != null && _products == null) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Could not load products.',
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_products == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_products!.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
_favouritesOnly
|
||||||
|
? 'Star products to keep them as favourites.'
|
||||||
|
: 'Confirmed receipt items will show up here.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (products.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No products match this search.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 16),
|
||||||
|
itemCount: products.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final product = products[index];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 0,
|
||||||
|
),
|
||||||
|
minLeadingWidth: _editing ? 72 : 36,
|
||||||
|
leading: _editing
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 36,
|
||||||
|
minHeight: 36,
|
||||||
|
),
|
||||||
|
onPressed: () => _removeProduct(product),
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: product.favourite
|
||||||
|
? 'Remove favourite'
|
||||||
|
: 'Favourite',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 36,
|
||||||
|
minHeight: 36,
|
||||||
|
),
|
||||||
|
onPressed: () => _toggleFavourite(product),
|
||||||
|
icon: Icon(
|
||||||
|
product.favourite ? Icons.star : Icons.star_outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: ProductImage(
|
||||||
|
name: product.name,
|
||||||
|
path: product.imagePath,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
product.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: product.supermarket == null
|
||||||
|
? null
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: StoreTag(
|
||||||
|
name: product.supermarket,
|
||||||
|
stores: stores,
|
||||||
|
compact: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
formatEuro(product.lastPrice),
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
onTap: _editing ? null : () => _openProduct(product),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/// User profile placeholder. Sign-in will be added later.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ProfileScreen extends StatelessWidget {
|
||||||
|
const ProfileScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Profile')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 40,
|
||||||
|
backgroundColor: scheme.secondaryContainer,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
size: 40,
|
||||||
|
color: scheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Guest',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Sign in, cloud sync, and account settings will show up here later.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import 'services/product_repository.dart';
|
|||||||
import 'services/receipt_parser.dart';
|
import 'services/receipt_parser.dart';
|
||||||
import 'utils/constants.dart';
|
import 'utils/constants.dart';
|
||||||
import 'utils/supermarkets.dart';
|
import 'utils/supermarkets.dart';
|
||||||
|
import 'widgets/profile_button.dart';
|
||||||
import 'widgets/settings_button.dart';
|
import 'widgets/settings_button.dart';
|
||||||
|
|
||||||
class ScanScreen extends StatefulWidget {
|
class ScanScreen extends StatefulWidget {
|
||||||
@@ -53,6 +54,8 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
}
|
}
|
||||||
} on UnimplementedError {
|
} on UnimplementedError {
|
||||||
// Desktop / platforms that do not support lost-data recovery.
|
// Desktop / platforms that do not support lost-data recovery.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Widget tests and hosts without the image_picker plugin.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +185,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text(kAppName),
|
title: const Text(kAppName),
|
||||||
actions: const [SettingsButton()],
|
actions: const [ProfileButton(), SettingsButton()],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ library;
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
import '../models/app_overview.dart';
|
||||||
import '../models/line_item.dart';
|
import '../models/line_item.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
import '../models/purchase.dart';
|
import '../models/purchase.dart';
|
||||||
@@ -16,15 +18,15 @@ import '../models/supermarket.dart';
|
|||||||
import '../utils/money.dart';
|
import '../utils/money.dart';
|
||||||
import '../utils/supermarkets.dart';
|
import '../utils/supermarkets.dart';
|
||||||
|
|
||||||
class ProductRepository {
|
class ProductRepository extends ChangeNotifier {
|
||||||
ProductRepository({this.databasePath});
|
ProductRepository({this.databasePath});
|
||||||
|
|
||||||
final String? databasePath;
|
final String? databasePath;
|
||||||
Database? _db;
|
Future<Database>? _opening;
|
||||||
|
|
||||||
Future<Database> get _database async {
|
Future<Database> get _database {
|
||||||
_db ??= await _open();
|
_opening ??= _open();
|
||||||
return _db!;
|
return _opening!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> _open() async {
|
Future<Database> _open() async {
|
||||||
@@ -33,7 +35,7 @@ class ProductRepository {
|
|||||||
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
|
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 6,
|
version: 7,
|
||||||
onConfigure: (db) async {
|
onConfigure: (db) async {
|
||||||
await db.execute('PRAGMA foreign_keys = ON');
|
await db.execute('PRAGMA foreign_keys = ON');
|
||||||
},
|
},
|
||||||
@@ -67,6 +69,9 @@ class ProductRepository {
|
|||||||
if (oldVersion < 6) {
|
if (oldVersion < 6) {
|
||||||
await _upgradeShopsToV6(db);
|
await _upgradeShopsToV6(db);
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 7) {
|
||||||
|
await _upgradeShopItemsProductFk(db);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,13 +140,13 @@ class ProductRepository {
|
|||||||
CREATE TABLE shop_items (
|
CREATE TABLE shop_items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
shop_id INTEGER NOT NULL,
|
shop_id INTEGER NOT NULL,
|
||||||
product_id INTEGER NOT NULL,
|
product_id INTEGER,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
price REAL NOT NULL,
|
price REAL NOT NULL,
|
||||||
quantity INTEGER,
|
quantity INTEGER,
|
||||||
unit_price REAL,
|
unit_price REAL,
|
||||||
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
|
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await _createShopTagTable(db);
|
await _createShopTagTable(db);
|
||||||
@@ -169,6 +174,31 @@ class ProductRepository {
|
|||||||
await _createShopTagTable(db);
|
await _createShopTagTable(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _upgradeShopItemsProductFk(DatabaseExecutor db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE shop_items_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
shop_id INTEGER NOT NULL,
|
||||||
|
product_id INTEGER,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
quantity INTEGER,
|
||||||
|
unit_price REAL,
|
||||||
|
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
INSERT INTO shop_items_new (
|
||||||
|
id, shop_id, product_id, name, price, quantity, unit_price
|
||||||
|
)
|
||||||
|
SELECT id, shop_id, product_id, name, price, quantity, unit_price
|
||||||
|
FROM shop_items
|
||||||
|
''');
|
||||||
|
await db.execute('DROP TABLE shop_items');
|
||||||
|
await db.execute('ALTER TABLE shop_items_new RENAME TO shop_items');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _createShoppingListTables(DatabaseExecutor db) async {
|
Future<void> _createShoppingListTables(DatabaseExecutor db) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS shopping_lists (
|
CREATE TABLE IF NOT EXISTS shopping_lists (
|
||||||
@@ -268,6 +298,7 @@ class ProductRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
return shopId;
|
return shopId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +384,51 @@ class ProductRepository {
|
|||||||
return rows.map(Product.fromMap).toList();
|
return rows.map(Product.fromMap).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<AppOverview> getOverview() async {
|
||||||
|
final db = await _database;
|
||||||
|
final productCount =
|
||||||
|
Sqflite.firstIntValue(
|
||||||
|
await db.rawQuery('SELECT COUNT(*) FROM products'),
|
||||||
|
) ??
|
||||||
|
0;
|
||||||
|
final favouriteCount =
|
||||||
|
Sqflite.firstIntValue(
|
||||||
|
await db.rawQuery(
|
||||||
|
'SELECT COUNT(*) FROM products WHERE favourite = 1',
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
0;
|
||||||
|
final tripCount =
|
||||||
|
Sqflite.firstIntValue(await db.rawQuery('SELECT COUNT(*) FROM shops')) ??
|
||||||
|
0;
|
||||||
|
final listCount =
|
||||||
|
Sqflite.firstIntValue(
|
||||||
|
await db.rawQuery('SELECT COUNT(*) FROM shopping_lists'),
|
||||||
|
) ??
|
||||||
|
0;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final monthStart = DateTime(now.year, now.month, 1).toIso8601String();
|
||||||
|
final monthRows = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*) AS trip_count, COALESCE(SUM(total), 0) AS month_total
|
||||||
|
FROM shops
|
||||||
|
WHERE shopped_at >= ?
|
||||||
|
''',
|
||||||
|
[monthStart],
|
||||||
|
);
|
||||||
|
final month = monthRows.first;
|
||||||
|
final shops = await getShops();
|
||||||
|
return AppOverview(
|
||||||
|
productCount: productCount,
|
||||||
|
favouriteCount: favouriteCount,
|
||||||
|
tripCount: tripCount,
|
||||||
|
monthTripCount: (month['trip_count'] as num?)?.toInt() ?? 0,
|
||||||
|
monthTotal: (month['month_total'] as num?)?.toDouble() ?? 0,
|
||||||
|
listCount: listCount,
|
||||||
|
recentShops: shops.take(3).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Product?> getProduct(int id) async {
|
Future<Product?> getProduct(int id) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
@@ -433,6 +509,7 @@ class ProductRepository {
|
|||||||
values['category'] = category.trim();
|
values['category'] = category.trim();
|
||||||
}
|
}
|
||||||
await db.update('products', values, where: 'id = ?', whereArgs: [id]);
|
await db.update('products', values, where: 'id = ?', whereArgs: [id]);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setFavourite(int id, bool favourite) =>
|
Future<void> setFavourite(int id, bool favourite) =>
|
||||||
@@ -444,6 +521,60 @@ class ProductRepository {
|
|||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteProduct(int id) async {
|
||||||
|
final db = await _database;
|
||||||
|
final rows = await db.query(
|
||||||
|
'products',
|
||||||
|
columns: ['image_path'],
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await txn.update(
|
||||||
|
'shop_items',
|
||||||
|
{'product_id': null},
|
||||||
|
where: 'product_id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
await txn.update(
|
||||||
|
'shopping_list_items',
|
||||||
|
{'product_id': null},
|
||||||
|
where: 'product_id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
await txn.delete('products', where: 'id = ?', whereArgs: [id]);
|
||||||
|
});
|
||||||
|
if (rows.isNotEmpty) {
|
||||||
|
await _tryDeleteFile(rows.first['image_path'] as String?);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAllProducts() async {
|
||||||
|
final db = await _database;
|
||||||
|
final rows = await db.query('products', columns: ['image_path']);
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await txn.update('shop_items', {'product_id': null});
|
||||||
|
await txn.update('shopping_list_items', {'product_id': null});
|
||||||
|
await txn.delete('products');
|
||||||
|
});
|
||||||
|
for (final row in rows) {
|
||||||
|
await _tryDeleteFile(row['image_path'] as String?);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tryDeleteFile(String? path) async {
|
||||||
|
if (path == null || path.isEmpty) return;
|
||||||
|
try {
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Purchase>> getPurchases(int productId) async {
|
Future<List<Purchase>> getPurchases(int productId) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final rows = await db.rawQuery(
|
final rows = await db.rawQuery(
|
||||||
@@ -576,6 +707,7 @@ class ProductRepository {
|
|||||||
await _replaceShopTags(txn, shopId: id, tags: tags);
|
await _replaceShopTags(txn, shopId: id, tags: tags);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _replaceShopTags(
|
Future<void> _replaceShopTags(
|
||||||
@@ -624,12 +756,8 @@ class ProductRepository {
|
|||||||
await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]);
|
await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]);
|
||||||
await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
|
await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
|
||||||
});
|
});
|
||||||
final path = shop?.receiptImagePath;
|
await _tryDeleteFile(shop?.receiptImagePath);
|
||||||
if (path == null || path.isEmpty) return;
|
notifyListeners();
|
||||||
final file = File(path);
|
|
||||||
if (await file.exists()) {
|
|
||||||
await file.delete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes one trip line. Catalog products are left unchanged.
|
/// Removes one trip line. Catalog products are left unchanged.
|
||||||
@@ -646,6 +774,7 @@ class ProductRepository {
|
|||||||
final shopId = rows.first['shop_id']! as int;
|
final shopId = rows.first['shop_id']! as int;
|
||||||
await db.delete('shop_items', where: 'id = ?', whereArgs: [id]);
|
await db.delete('shop_items', where: 'id = ?', whereArgs: [id]);
|
||||||
await _recalculateShopTotal(shopId);
|
await _recalculateShopTotal(shopId);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateShopItem({
|
Future<void> updateShopItem({
|
||||||
@@ -672,6 +801,7 @@ class ProductRepository {
|
|||||||
if (values.isEmpty) return;
|
if (values.isEmpty) return;
|
||||||
await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]);
|
await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]);
|
||||||
await _recalculateShopTotal(shopId);
|
await _recalculateShopTotal(shopId);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _recalculateShopTotal(int shopId) async {
|
Future<void> _recalculateShopTotal(int shopId) async {
|
||||||
@@ -732,11 +862,13 @@ class ProductRepository {
|
|||||||
Future<int> createShoppingList(String name) async {
|
Future<int> createShoppingList(String name) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final now = DateTime.now().toIso8601String();
|
final now = DateTime.now().toIso8601String();
|
||||||
return db.insert('shopping_lists', {
|
final id = await db.insert('shopping_lists', {
|
||||||
'name': name.trim(),
|
'name': name.trim(),
|
||||||
'created_at': now,
|
'created_at': now,
|
||||||
'updated_at': now,
|
'updated_at': now,
|
||||||
});
|
});
|
||||||
|
notifyListeners();
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> renameShoppingList(int id, String name) async {
|
Future<void> renameShoppingList(int id, String name) async {
|
||||||
@@ -747,6 +879,7 @@ class ProductRepository {
|
|||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteShoppingList(int id) async {
|
Future<void> deleteShoppingList(int id) async {
|
||||||
@@ -759,6 +892,7 @@ class ProductRepository {
|
|||||||
);
|
);
|
||||||
await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]);
|
await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]);
|
||||||
});
|
});
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ShoppingListItem>> getShoppingListItems(int listId) async {
|
Future<List<ShoppingListItem>> getShoppingListItems(int listId) async {
|
||||||
@@ -792,6 +926,7 @@ class ProductRepository {
|
|||||||
'sort_order': (max ?? -1) + 1,
|
'sort_order': (max ?? -1) + 1,
|
||||||
});
|
});
|
||||||
await _touchShoppingList(listId);
|
await _touchShoppingList(listId);
|
||||||
|
notifyListeners();
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +957,7 @@ class ProductRepository {
|
|||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
await _touchShoppingList(rows.first['list_id']! as int);
|
await _touchShoppingList(rows.first['list_id']! as int);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteShoppingListItem(int id) async {
|
Future<void> deleteShoppingListItem(int id) async {
|
||||||
@@ -836,6 +972,7 @@ class ProductRepository {
|
|||||||
if (rows.isEmpty) return;
|
if (rows.isEmpty) return;
|
||||||
await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]);
|
await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]);
|
||||||
await _touchShoppingList(rows.first['list_id']! as int);
|
await _touchShoppingList(rows.first['list_id']! as int);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _touchShoppingList(int id) async {
|
Future<void> _touchShoppingList(int id) async {
|
||||||
@@ -1026,6 +1163,7 @@ class ProductRepository {
|
|||||||
await _resetSequence(txn, 'shopping_lists');
|
await _resetSequence(txn, 'shopping_lists');
|
||||||
await _resetSequence(txn, 'shopping_list_items');
|
await _resetSequence(txn, 'shopping_list_items');
|
||||||
});
|
});
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
|
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = _load();
|
_future = _load();
|
||||||
|
widget.repository.addListener(_reload);
|
||||||
widget.repository
|
widget.repository
|
||||||
.getUsedShopTags()
|
.getUsedShopTags()
|
||||||
.then((tags) {
|
.then((tags) {
|
||||||
@@ -47,6 +48,12 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
|||||||
.catchError((_) {});
|
.catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.repository.removeListener(_reload);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<({Shop shop, List<ShopItem> items})> _load() async {
|
Future<({Shop shop, List<ShopItem> items})> _load() async {
|
||||||
final shop = await widget.repository.getShop(widget.shopId);
|
final shop = await widget.repository.getShop(widget.shopId);
|
||||||
if (shop == null) {
|
if (shop == null) {
|
||||||
@@ -57,6 +64,7 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
void _reload() {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _future = _load());
|
setState(() => _future = _load());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +284,9 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
|||||||
formatEuro(item.price),
|
formatEuro(item.price),
|
||||||
style: theme.textTheme.titleMedium,
|
style: theme.textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
onTap: _editing ? null : () => _openProduct(item.productId),
|
onTap: _editing || item.productId == null
|
||||||
|
? null
|
||||||
|
: () => _openProduct(item.productId!),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|||||||
+250
-154
@@ -13,6 +13,7 @@ import 'utils/money.dart';
|
|||||||
import 'utils/shop_calendar.dart';
|
import 'utils/shop_calendar.dart';
|
||||||
import 'utils/shop_log_filter.dart';
|
import 'utils/shop_log_filter.dart';
|
||||||
import 'widgets/confirm_dialog.dart';
|
import 'widgets/confirm_dialog.dart';
|
||||||
|
import 'widgets/profile_button.dart';
|
||||||
import 'widgets/settings_button.dart';
|
import 'widgets/settings_button.dart';
|
||||||
import 'widgets/shop_heatmap.dart';
|
import 'widgets/shop_heatmap.dart';
|
||||||
import 'widgets/store_tag.dart';
|
import 'widgets/store_tag.dart';
|
||||||
@@ -28,8 +29,10 @@ class ShopLogScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShopLogScreenState extends State<ShopLogScreen> {
|
class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||||
late Future<({List<Shop> shops, Map<int, List<String>> productNames})>
|
List<Shop>? _shops;
|
||||||
_future;
|
Map<int, List<String>> _productNames = const {};
|
||||||
|
Object? _error;
|
||||||
|
int _loadToken = 0;
|
||||||
final _search = TextEditingController();
|
final _search = TextEditingController();
|
||||||
bool _editing = false;
|
bool _editing = false;
|
||||||
bool _searching = false;
|
bool _searching = false;
|
||||||
@@ -37,28 +40,45 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
|||||||
DateTime? _selectedDay;
|
DateTime? _selectedDay;
|
||||||
String? _tagFilter;
|
String? _tagFilter;
|
||||||
|
|
||||||
|
bool get _hasTrips => _shops != null && _shops!.isNotEmpty;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = _load();
|
widget.repository.addListener(_load);
|
||||||
_search.addListener(() => setState(() {}));
|
_search.addListener(() => setState(() {}));
|
||||||
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.repository.removeListener(_load);
|
||||||
_search.dispose();
|
_search.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({List<Shop> shops, Map<int, List<String>> productNames})>
|
Future<void> _load() async {
|
||||||
_load() async {
|
final token = ++_loadToken;
|
||||||
final shops = await widget.repository.getShops();
|
try {
|
||||||
final productNames = await widget.repository.getShopItemNamesByShop();
|
final shops = await widget.repository.getShops();
|
||||||
return (shops: shops, productNames: productNames);
|
final productNames = await widget.repository.getShopItemNamesByShop();
|
||||||
}
|
if (!mounted || token != _loadToken) return;
|
||||||
|
setState(() {
|
||||||
void _reload() {
|
_shops = shops;
|
||||||
setState(() => _future = _load());
|
_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 {
|
Future<void> _openShop(Shop shop) async {
|
||||||
@@ -68,18 +88,31 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
|||||||
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
|
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (mounted) _reload();
|
if (mounted) _load();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _removeShop(Shop shop) async {
|
Future<void> _removeShop(Shop shop) async {
|
||||||
final confirmed = await confirmAction(
|
final confirmed = await confirmAction(
|
||||||
context,
|
context,
|
||||||
title: 'Remove shopping trip?',
|
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;
|
if (!confirmed || !mounted) return;
|
||||||
await widget.repository.deleteShop(shop.id);
|
setState(() {
|
||||||
if (mounted) _reload();
|
_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) {
|
void _selectDay(DateTime? day) {
|
||||||
@@ -120,142 +153,205 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: _searching ? 'Close search' : 'Search products',
|
tooltip: _searching ? 'Close search' : 'Search products',
|
||||||
onPressed: () {
|
onPressed: _hasTrips
|
||||||
setState(() {
|
? () {
|
||||||
_searching = !_searching;
|
setState(() {
|
||||||
if (!_searching) _search.clear();
|
_searching = !_searching;
|
||||||
});
|
if (!_searching) _search.clear();
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
icon: Icon(_searching ? Icons.close : Icons.search),
|
icon: Icon(_searching ? Icons.close : Icons.search),
|
||||||
),
|
),
|
||||||
IconButton(
|
if (_hasTrips)
|
||||||
tooltip: _editing ? 'Done' : 'Edit',
|
IconButton(
|
||||||
onPressed: () => setState(() => _editing = !_editing),
|
tooltip: _editing ? 'Done' : 'Edit',
|
||||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
onPressed: () => setState(() => _editing = !_editing),
|
||||||
),
|
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||||
|
),
|
||||||
|
const ProfileButton(),
|
||||||
const SettingsButton(),
|
const SettingsButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: FutureBuilder(
|
body: _ShopLogBody(
|
||||||
future: _future,
|
theme: theme,
|
||||||
builder: (context, snapshot) {
|
stores: stores,
|
||||||
if (snapshot.hasError) {
|
nowMonth: nowMonth,
|
||||||
return Center(
|
shops: _shops,
|
||||||
child: Text(
|
productNames: _productNames,
|
||||||
'Could not load the shop log.',
|
error: _error,
|
||||||
style: theme.textTheme.bodyLarge,
|
month: _month,
|
||||||
),
|
selectedDay: _selectedDay,
|
||||||
);
|
tagFilter: _tagFilter,
|
||||||
}
|
query: _search.text,
|
||||||
if (!snapshot.hasData) {
|
editing: _editing,
|
||||||
return const Center(child: CircularProgressIndicator());
|
onPreviousMonth: () => _goMonth(-1),
|
||||||
}
|
onNextMonth: () => _goMonth(1),
|
||||||
|
onSelectDay: _selectDay,
|
||||||
final shops = snapshot.data!.shops;
|
onClearTag: () => setState(() => _tagFilter = null),
|
||||||
final productNames = snapshot.data!.productNames;
|
onTagTap: (tag) {
|
||||||
final counts = tripCountsByDay([
|
setState(() {
|
||||||
for (final shop in shops)
|
_tagFilter = _tagFilter?.toLowerCase() == tag.toLowerCase()
|
||||||
if (isSameMonth(shop.shoppedAt, _month)) shop.shoppedAt,
|
? null
|
||||||
]);
|
: tag;
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
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 {
|
class _ShopLogTile extends StatelessWidget {
|
||||||
const _ShopLogTile({
|
const _ShopLogTile({
|
||||||
required this.shop,
|
required this.shop,
|
||||||
@@ -295,6 +391,12 @@ class _ShopLogTile extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (editing)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: onRemove,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -326,19 +428,13 @@ class _ShopLogTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
editing
|
Padding(
|
||||||
? IconButton(
|
padding: const EdgeInsets.only(top: 8, right: 8),
|
||||||
tooltip: 'Remove',
|
child: Text(
|
||||||
onPressed: onRemove,
|
formatEuro(shop.total),
|
||||||
icon: const Icon(Icons.delete_outline),
|
style: theme.textTheme.titleMedium,
|
||||||
)
|
),
|
||||||
: Padding(
|
),
|
||||||
padding: const EdgeInsets.only(top: 8, right: 8),
|
|
||||||
child: Text(
|
|
||||||
formatEuro(shop.total),
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = _load();
|
_future = _load();
|
||||||
|
widget.repository.addListener(_reload);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.repository.removeListener(_reload);
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async {
|
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async {
|
||||||
@@ -45,6 +52,7 @@ class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
void _reload() {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _future = _load());
|
setState(() => _future = _load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'services/product_repository.dart';
|
|||||||
import 'shopping_list_detail_screen.dart';
|
import 'shopping_list_detail_screen.dart';
|
||||||
import 'widgets/confirm_dialog.dart';
|
import 'widgets/confirm_dialog.dart';
|
||||||
import 'widgets/name_dialog.dart';
|
import 'widgets/name_dialog.dart';
|
||||||
|
import 'widgets/profile_button.dart';
|
||||||
import 'widgets/settings_button.dart';
|
import 'widgets/settings_button.dart';
|
||||||
|
|
||||||
class ShoppingListsScreen extends StatefulWidget {
|
class ShoppingListsScreen extends StatefulWidget {
|
||||||
@@ -20,17 +21,40 @@ class ShoppingListsScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
|
class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
|
||||||
late Future<List<ShoppingList>> _future;
|
List<ShoppingList>? _lists;
|
||||||
|
Object? _error;
|
||||||
bool _editing = false;
|
bool _editing = false;
|
||||||
|
int _loadToken = 0;
|
||||||
|
|
||||||
|
bool get _hasLists => _lists != null && _lists!.isNotEmpty;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = widget.repository.getShoppingLists();
|
widget.repository.addListener(_load);
|
||||||
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reload() {
|
@override
|
||||||
setState(() => _future = widget.repository.getShoppingLists());
|
void dispose() {
|
||||||
|
widget.repository.removeListener(_load);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final token = ++_loadToken;
|
||||||
|
try {
|
||||||
|
final lists = await widget.repository.getShoppingLists();
|
||||||
|
if (!mounted || token != _loadToken) return;
|
||||||
|
setState(() {
|
||||||
|
_lists = lists;
|
||||||
|
_error = null;
|
||||||
|
if (lists.isEmpty) _editing = false;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted || token != _loadToken) return;
|
||||||
|
setState(() => _error = error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _create() async {
|
Future<void> _create() async {
|
||||||
@@ -53,7 +77,7 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
|
|||||||
ShoppingListDetailScreen(repository: widget.repository, listId: id),
|
ShoppingListDetailScreen(repository: widget.repository, listId: id),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (mounted) _reload();
|
if (mounted) _load();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _removeList(ShoppingList list) async {
|
Future<void> _removeList(ShoppingList list) async {
|
||||||
@@ -63,8 +87,15 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
|
|||||||
message: '"${list.name}" and its items will be deleted.',
|
message: '"${list.name}" and its items will be deleted.',
|
||||||
);
|
);
|
||||||
if (!confirmed || !mounted) return;
|
if (!confirmed || !mounted) return;
|
||||||
await widget.repository.deleteShoppingList(list.id);
|
setState(() {
|
||||||
if (mounted) _reload();
|
_lists = [for (final item in _lists ?? const <ShoppingList>[]) if (item.id != list.id) item];
|
||||||
|
if (_lists!.isEmpty) _editing = false;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.repository.deleteShoppingList(list.id);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,11 +106,13 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Shopping lists'),
|
title: const Text('Shopping lists'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
if (_hasLists)
|
||||||
tooltip: _editing ? 'Done' : 'Edit',
|
IconButton(
|
||||||
onPressed: () => setState(() => _editing = !_editing),
|
tooltip: _editing ? 'Done' : 'Edit',
|
||||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
onPressed: () => setState(() => _editing = !_editing),
|
||||||
),
|
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||||
|
),
|
||||||
|
const ProfileButton(),
|
||||||
const SettingsButton(),
|
const SettingsButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -88,64 +121,53 @@ class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
|
|||||||
tooltip: 'New list',
|
tooltip: 'New list',
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
body: FutureBuilder<List<ShoppingList>>(
|
body: _error != null && _lists == null
|
||||||
future: _future,
|
? Center(
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'Could not load shopping lists.',
|
'Could not load shopping lists.',
|
||||||
style: theme.textTheme.bodyLarge,
|
style: theme.textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
}
|
: _lists == null
|
||||||
if (!snapshot.hasData) {
|
? const Center(child: CircularProgressIndicator())
|
||||||
return const Center(child: CircularProgressIndicator());
|
: _lists!.isEmpty
|
||||||
}
|
? Center(
|
||||||
|
|
||||||
final lists = snapshot.data!;
|
|
||||||
if (lists.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'Create a list for your next shop.',
|
'Create a list for your next shop.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyLarge,
|
style: theme.textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
}
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
|
||||||
return ListView.separated(
|
itemCount: _lists!.length,
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
|
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||||
itemCount: lists.length,
|
itemBuilder: (context, index) {
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
final list = _lists![index];
|
||||||
itemBuilder: (context, index) {
|
final remaining = list.itemCount - list.checkedCount;
|
||||||
final list = lists[index];
|
return ListTile(
|
||||||
final remaining = list.itemCount - list.checkedCount;
|
leading: CircleAvatar(child: Text('${list.itemCount}')),
|
||||||
return ListTile(
|
title: Text(list.name),
|
||||||
leading: CircleAvatar(child: Text('${list.itemCount}')),
|
subtitle: Text(
|
||||||
title: Text(list.name),
|
list.itemCount == 0
|
||||||
subtitle: Text(
|
? 'Empty'
|
||||||
list.itemCount == 0
|
: remaining == 0
|
||||||
? 'Empty'
|
? 'All done'
|
||||||
: remaining == 0
|
: remaining == 1
|
||||||
? 'All done'
|
? '1 item left'
|
||||||
: remaining == 1
|
: '$remaining items left',
|
||||||
? '1 item left'
|
),
|
||||||
: '$remaining items left',
|
trailing: _editing
|
||||||
),
|
? IconButton(
|
||||||
trailing: _editing
|
tooltip: 'Delete',
|
||||||
? IconButton(
|
onPressed: () => _removeList(list),
|
||||||
tooltip: 'Delete',
|
icon: const Icon(Icons.delete_outline),
|
||||||
onPressed: () => _removeList(list),
|
)
|
||||||
icon: const Icon(Icons.delete_outline),
|
: const Icon(Icons.chevron_right),
|
||||||
)
|
onTap: _editing ? null : () => _openList(list.id),
|
||||||
: const Icon(Icons.chevron_right),
|
);
|
||||||
onTap: _editing ? null : () => _openList(list.id),
|
},
|
||||||
);
|
),
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// Opens the profile screen from an app bar.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../profile_screen.dart';
|
||||||
|
|
||||||
|
class ProfileButton extends StatelessWidget {
|
||||||
|
const ProfileButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Profile',
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute(builder: (_) => const ProfileScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.person_outline),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,6 +256,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
google_mlkit_commons:
|
google_mlkit_commons:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -464,6 +472,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.19.2"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -637,6 +653,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.11"
|
version: "2.5.11"
|
||||||
|
sqflite_common_ffi:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: sqflite_common_ffi
|
||||||
|
sha256: d5564f1308cafbf064be498f2beb1e993e742985a7cca9e2e228d6cbccd84a05
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2+1"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -653,6 +677,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
sqlite3:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.5.2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
sqflite_common_ffi: ^2.4.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:receipity/models/line_item.dart';
|
||||||
|
|
||||||
|
import 'test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
ensureTestSqlite();
|
||||||
|
|
||||||
|
test('deleting a product keeps the trip line and drops the catalog row',
|
||||||
|
() async {
|
||||||
|
final repository = memoryRepository();
|
||||||
|
final shopId = await repository.saveShop(
|
||||||
|
supermarket: 'Jumbo',
|
||||||
|
shoppedAt: DateTime(2026, 8, 12),
|
||||||
|
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
||||||
|
);
|
||||||
|
|
||||||
|
final products = await repository.getAll();
|
||||||
|
expect(products, hasLength(1));
|
||||||
|
await repository.deleteProduct(products.single.id);
|
||||||
|
|
||||||
|
expect(await repository.getAll(), isEmpty);
|
||||||
|
final items = await repository.getShopItems(shopId);
|
||||||
|
expect(items, hasLength(1));
|
||||||
|
expect(items.single.name, 'Milk');
|
||||||
|
expect(items.single.productId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting all products leaves shopping trips in place', () async {
|
||||||
|
final repository = memoryRepository();
|
||||||
|
await repository.saveShop(
|
||||||
|
supermarket: 'Jumbo',
|
||||||
|
shoppedAt: DateTime(2026, 8, 12),
|
||||||
|
items: [
|
||||||
|
LineItem(id: '1', name: 'Milk', price: 1.5),
|
||||||
|
LineItem(id: '2', name: 'Bread', price: 2),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await repository.deleteAllProducts();
|
||||||
|
|
||||||
|
expect(await repository.getAll(), isEmpty);
|
||||||
|
expect(await repository.getShops(), hasLength(1));
|
||||||
|
final overview = await repository.getOverview();
|
||||||
|
expect(overview.productCount, 0);
|
||||||
|
expect(overview.tripCount, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:receipity/services/product_repository.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
var _dbSeq = 0;
|
||||||
|
var _sqliteReady = false;
|
||||||
|
|
||||||
|
void ensureTestSqlite() {
|
||||||
|
if (_sqliteReady) return;
|
||||||
|
sqfliteFfiInit();
|
||||||
|
databaseFactory = databaseFactoryFfi;
|
||||||
|
_sqliteReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductRepository memoryRepository() {
|
||||||
|
ensureTestSqlite();
|
||||||
|
final dir = Directory(p.join('.dart_tool', 'receipity_test_db'))
|
||||||
|
..createSync(recursive: true);
|
||||||
|
final name =
|
||||||
|
'db_${DateTime.now().microsecondsSinceEpoch}_${_dbSeq++}_${Random().nextInt(1 << 32)}.sqlite';
|
||||||
|
return ProductRepository(databasePath: p.join(dir.path, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpUntilFound(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
int attempts = 40,
|
||||||
|
}) async {
|
||||||
|
for (var i = 0; i < attempts; i++) {
|
||||||
|
await tester.pump();
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 20)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw TestFailure('Timed out waiting for $finder');
|
||||||
|
}
|
||||||
+133
-17
@@ -3,14 +3,34 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:receipity/main.dart';
|
import 'package:receipity/main.dart';
|
||||||
import 'package:receipity/models/line_item.dart';
|
import 'package:receipity/models/line_item.dart';
|
||||||
import 'package:receipity/review_screen.dart';
|
import 'package:receipity/review_screen.dart';
|
||||||
import 'package:receipity/services/product_repository.dart';
|
|
||||||
import 'package:receipity/widgets/shop_heatmap.dart';
|
import 'package:receipity/widgets/shop_heatmap.dart';
|
||||||
|
|
||||||
|
import 'test_helpers.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('scan screen shows capture actions and bottom navigation', (
|
ensureTestSqlite();
|
||||||
|
|
||||||
|
testWidgets('home screen shows overview and bottom navigation', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(ReceipityApp());
|
await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
|
||||||
|
await pumpUntilFound(tester, find.text('Nothing logged yet.'));
|
||||||
|
|
||||||
|
expect(find.text('Home'), findsWidgets);
|
||||||
|
expect(find.text('Products'), findsOneWidget);
|
||||||
|
expect(find.text('Lists'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Add'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.add), findsWidgets);
|
||||||
|
expect(find.text('Shop log'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Profile'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Settings'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('scan screen shows capture actions', (tester) async {
|
||||||
|
await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
|
||||||
|
await pumpUntilFound(tester, find.text('Overview'));
|
||||||
|
await tester.tap(find.byTooltip('Add'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('Receipity'), findsOneWidget);
|
expect(find.text('Receipity'), findsOneWidget);
|
||||||
expect(find.text('Take photo'), findsOneWidget);
|
expect(find.text('Take photo'), findsOneWidget);
|
||||||
@@ -21,18 +41,111 @@ void main() {
|
|||||||
find.textContaining('Scan a receipt or enter a trip by hand'),
|
find.textContaining('Scan a receipt or enter a trip by hand'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.text('Products'), findsOneWidget);
|
|
||||||
expect(find.text('Lists'), findsOneWidget);
|
|
||||||
expect(find.byTooltip('Add'), findsOneWidget);
|
|
||||||
expect(find.byIcon(Icons.add), findsOneWidget);
|
|
||||||
expect(find.text('Shop log'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shop log has product search', (tester) async {
|
testWidgets('empty shop log hides edit and disables search', (tester) async {
|
||||||
await tester.pumpWidget(ReceipityApp());
|
await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
|
||||||
|
await pumpUntilFound(tester, find.text('Overview'));
|
||||||
await tester.tap(find.text('Shop log'));
|
await tester.tap(find.text('Shop log'));
|
||||||
await tester.pump();
|
await pumpUntilFound(
|
||||||
|
tester,
|
||||||
|
find.text('Scan a receipt or enter a trip to log it.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Edit'), findsNothing);
|
||||||
expect(find.byTooltip('Search products'), findsOneWidget);
|
expect(find.byTooltip('Search products'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<IconButton>(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byIcon(Icons.search),
|
||||||
|
matching: find.byType(IconButton),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('deleting a trip removes it without leaving the screen', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = memoryRepository();
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await repository.saveShop(
|
||||||
|
supermarket: 'Jumbo',
|
||||||
|
shoppedAt: DateTime.now(),
|
||||||
|
name: 'Weekly shop',
|
||||||
|
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(ReceipityApp(repository: repository));
|
||||||
|
await pumpUntilFound(tester, find.text('Overview'));
|
||||||
|
await tester.tap(find.text('Shop log'));
|
||||||
|
await pumpUntilFound(tester, find.text('Weekly shop'));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byTooltip('Remove'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Delete'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Weekly shop'), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.text('Scan a receipt or enter a trip to log it.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byTooltip('Edit'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('products edit mode shows delete, star, and wipe all', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = memoryRepository();
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await repository.saveShop(
|
||||||
|
supermarket: 'Jumbo',
|
||||||
|
shoppedAt: DateTime.now(),
|
||||||
|
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(ReceipityApp(repository: repository));
|
||||||
|
await pumpUntilFound(tester, find.text('Overview'));
|
||||||
|
await tester.tap(find.text('Products').last);
|
||||||
|
await pumpUntilFound(tester, find.text('Milk'));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byTooltip('Delete'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Favourite'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Delete all products'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Delete all products'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Delete'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Milk'), findsNothing);
|
||||||
|
expect(find.byTooltip('Edit'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('profile screen is a placeholder', (tester) async {
|
||||||
|
await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
|
||||||
|
await pumpUntilFound(tester, find.text('Overview'));
|
||||||
|
await tester.tap(find.byTooltip('Profile'));
|
||||||
|
await pumpUntilFound(tester, find.text('Guest'));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shop heatmap renders the visible month', (tester) async {
|
testWidgets('shop heatmap renders the visible month', (tester) async {
|
||||||
@@ -59,15 +172,18 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('enter manually opens a blank trip', (tester) async {
|
testWidgets('enter manually opens a blank trip', (tester) async {
|
||||||
await tester.pumpWidget(ReceipityApp());
|
await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
|
||||||
|
await pumpUntilFound(tester, find.text('Overview'));
|
||||||
|
await tester.tap(find.byTooltip('Add'));
|
||||||
|
await tester.pump();
|
||||||
await tester.tap(find.text('Enter manually'));
|
await tester.tap(find.text('Enter manually'));
|
||||||
await tester.pumpAndSettle();
|
await pumpUntilFound(tester, find.text('New trip'));
|
||||||
expect(find.text('New trip'), findsOneWidget);
|
|
||||||
expect(find.text('Add item'), findsOneWidget);
|
expect(find.text('Add item'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('lists tab shows shopping lists', (tester) async {
|
testWidgets('lists tab shows shopping lists', (tester) async {
|
||||||
await tester.pumpWidget(ReceipityApp());
|
await tester.pumpWidget(ReceipityApp(repository: memoryRepository()));
|
||||||
|
await pumpUntilFound(tester, find.text('Overview'));
|
||||||
await tester.tap(find.text('Lists'));
|
await tester.tap(find.text('Lists'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('Shopping lists'), findsOneWidget);
|
expect(find.text('Shopping lists'), findsOneWidget);
|
||||||
@@ -82,7 +198,7 @@ void main() {
|
|||||||
LineItem(id: '2', name: 'Bread', price: 2),
|
LineItem(id: '2', name: 'Bread', price: 2),
|
||||||
],
|
],
|
||||||
rawText: 'Milk 1.50\nBread 2.00',
|
rawText: 'Milk 1.50\nBread 2.00',
|
||||||
repository: ProductRepository(databasePath: ':memory:'),
|
repository: memoryRepository(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -109,7 +225,7 @@ void main() {
|
|||||||
home: ReviewScreen(
|
home: ReviewScreen(
|
||||||
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
items: [LineItem(id: '1', name: 'Milk', price: 1.5)],
|
||||||
rawText: 'Jumbo\nMilk 1.50',
|
rawText: 'Jumbo\nMilk 1.50',
|
||||||
repository: ProductRepository(databasePath: ':memory:'),
|
repository: memoryRepository(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user