home added
This commit is contained in:
+262
-110
@@ -4,12 +4,15 @@ library;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'models/product.dart';
|
||||
import 'models/supermarket.dart';
|
||||
import 'product_detail_screen.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'utils/fuzzy.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/product_image.dart';
|
||||
import 'widgets/profile_button.dart';
|
||||
import 'widgets/settings_button.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
|
||||
@@ -26,31 +29,52 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
||||
ProductSort _sort = ProductSort.preferred;
|
||||
bool _favouritesOnly = false;
|
||||
bool _searching = false;
|
||||
bool _editing = false;
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
widget.repository.addListener(_load);
|
||||
_search.addListener(() => setState(() {}));
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.repository.removeListener(_load);
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<List<Product>> _load() {
|
||||
return widget.repository.getAll(
|
||||
sort: _sort,
|
||||
favouritesOnly: _favouritesOnly,
|
||||
);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = _load());
|
||||
Future<void> _load() async {
|
||||
final token = ++_loadToken;
|
||||
try {
|
||||
final products = await widget.repository.getAll(
|
||||
sort: _sort,
|
||||
favouritesOnly: _favouritesOnly,
|
||||
);
|
||||
if (!mounted || token != _loadToken) return;
|
||||
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) {
|
||||
@@ -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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
||||
final products = _products == null ? const <Product>[] : _filter(_products!);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -106,17 +205,38 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
||||
)
|
||||
: const Text('Products'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _searching ? 'Close search' : 'Search',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searching = !_searching;
|
||||
if (!_searching) _search.clear();
|
||||
});
|
||||
},
|
||||
icon: Icon(_searching ? Icons.close : Icons.search),
|
||||
),
|
||||
const SettingsButton(),
|
||||
if (!_editing)
|
||||
IconButton(
|
||||
tooltip: _searching ? 'Close search' : 'Search',
|
||||
onPressed: _hasProducts
|
||||
? () {
|
||||
setState(() {
|
||||
_searching = !_searching;
|
||||
if (!_searching) _search.clear();
|
||||
});
|
||||
}
|
||||
: 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(
|
||||
@@ -141,8 +261,8 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
||||
onSelectionChanged: (selected) {
|
||||
setState(() {
|
||||
_sort = selected.first;
|
||||
_future = _load();
|
||||
});
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -153,8 +273,8 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_favouritesOnly = !_favouritesOnly;
|
||||
_future = _load();
|
||||
});
|
||||
_load();
|
||||
},
|
||||
icon: Icon(
|
||||
_favouritesOnly ? Icons.star : Icons.star_outline,
|
||||
@@ -163,93 +283,125 @@ class _ProductsScreenState extends State<ProductsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildList(theme, stores, products)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user