408 lines
12 KiB
Dart
408 lines
12 KiB
Dart
/// Catalog of products from confirmed receipts.
|
||
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';
|
||
|
||
class ProductsScreen extends StatefulWidget {
|
||
const ProductsScreen({super.key, required this.repository});
|
||
|
||
final ProductRepository repository;
|
||
|
||
@override
|
||
State<ProductsScreen> createState() => _ProductsScreenState();
|
||
}
|
||
|
||
class _ProductsScreenState extends State<ProductsScreen> {
|
||
ProductSort _sort = ProductSort.preferred;
|
||
bool _favouritesOnly = false;
|
||
bool _searching = false;
|
||
bool _editing = false;
|
||
final TextEditingController _search = TextEditingController();
|
||
List<Product>? _products;
|
||
Object? _error;
|
||
int _loadToken = 0;
|
||
|
||
bool get _hasProducts => _products != null && _products!.isNotEmpty;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
widget.repository.addListener(_load);
|
||
_search.addListener(() => setState(() {}));
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
widget.repository.removeListener(_load);
|
||
_search.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
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) {
|
||
final query = _search.text.trim();
|
||
if (query.isEmpty) return products;
|
||
final ranked = <({Product product, int score})>[];
|
||
for (final product in products) {
|
||
final haystack = [
|
||
product.name,
|
||
if (product.supermarket != null) product.supermarket!,
|
||
if (product.category != null) product.category!,
|
||
if (product.notes != null) product.notes!,
|
||
].join(' ');
|
||
final score = fuzzyScore(query, haystack);
|
||
if (score != null) {
|
||
ranked.add((product: product, score: score));
|
||
}
|
||
}
|
||
ranked.sort((a, b) => b.score.compareTo(a.score));
|
||
return [for (final row in ranked) row.product];
|
||
}
|
||
|
||
Future<void> _openProduct(Product product) async {
|
||
await Navigator.of(context).push<void>(
|
||
MaterialPageRoute(
|
||
builder: (_) => ProductDetailScreen(
|
||
repository: widget.repository,
|
||
productId: product.id,
|
||
),
|
||
),
|
||
);
|
||
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(
|
||
title: _searching
|
||
? TextField(
|
||
controller: _search,
|
||
autofocus: true,
|
||
decoration: const InputDecoration(
|
||
hintText: 'Search products',
|
||
border: InputBorder.none,
|
||
enabledBorder: InputBorder.none,
|
||
focusedBorder: InputBorder.none,
|
||
filled: false,
|
||
),
|
||
)
|
||
: const Text('Products'),
|
||
actions: [
|
||
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(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: SegmentedButton<ProductSort>(
|
||
segments: const [
|
||
ButtonSegment(
|
||
value: ProductSort.preferred,
|
||
label: Text('Preferred'),
|
||
),
|
||
ButtonSegment(
|
||
value: ProductSort.name,
|
||
label: Text('A–Z'),
|
||
),
|
||
],
|
||
selected: {_sort},
|
||
onSelectionChanged: (selected) {
|
||
setState(() {
|
||
_sort = selected.first;
|
||
});
|
||
_load();
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
IconButton.filledTonal(
|
||
tooltip: 'Favourites',
|
||
isSelected: _favouritesOnly,
|
||
onPressed: () {
|
||
setState(() {
|
||
_favouritesOnly = !_favouritesOnly;
|
||
});
|
||
_load();
|
||
},
|
||
icon: Icon(
|
||
_favouritesOnly ? Icons.star : Icons.star_outline,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|