Files
receipity/lib/products_screen.dart
T
2026-08-31 17:03:03 +02:00

446 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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 'utils/product_names.dart';
import 'widgets/confirm_dialog.dart';
import 'widgets/product_image.dart';
import 'widgets/product_name_conflicts.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 _searching = false;
bool _editing = false;
final TextEditingController _search = TextEditingController();
List<Product>? _products;
ProductNameIssues _nameIssues = const ProductNameIssues(
mergeGroups: [],
spellingVariants: [],
);
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);
final nameIssues = await widget.repository.getProductNameIssues();
if (!mounted || token != _loadToken) return;
setState(() {
_products = products;
_nameIssues = nameIssues;
_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();
}
}
Set<int> get _issueProductIds => _nameIssues.productIds;
Future<void> _resolveNameIssues({int? focusProductId}) async {
final issues = await widget.repository.getProductNameIssues();
if (!mounted || issues.isEmpty) return;
final resolved = await showProductNameConflictSheet(
context,
repository: widget.repository,
issues: issues,
focusProductId: focusProductId,
);
if (resolved && 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 (_sort == ProductSort.favourite && !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 && _nameIssues.isNotEmpty && !_editing)
IconButton(
tooltip: 'Resolve name issues',
onPressed: _resolveNameIssues,
icon: const Icon(kProductNameConflictIcon),
),
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: SegmentedButton<ProductSort>(
segments: const [
ButtonSegment(
value: ProductSort.name,
label: Text('A–Z'),
),
ButtonSegment(
value: ProductSort.preferred,
label: Text('Frequent'),
),
ButtonSegment(
value: ProductSort.favourite,
icon: Icon(Icons.star),
label: Text('Star'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
});
_load();
},
),
),
if (_nameIssues.isNotEmpty && !_editing)
ProductNameConflictBanner(
issues: _nameIssues,
onTap: _resolveNameIssues,
),
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(
_sort == ProductSort.favourite
? '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];
final hasIssue = _issueProductIds.contains(product.id);
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,
),
),
],
)
: Stack(
clipBehavior: Clip.none,
children: [
ProductImage(
name: product.name,
path: product.imagePath,
size: 36,
),
if (hasIssue && !_editing)
Positioned(
right: -2,
top: -2,
child: ProductNameConflictMarker(
compact: true,
onTap: () =>
_resolveNameIssues(focusProductId: product.id),
),
),
],
),
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: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasIssue && !_editing)
Padding(
padding: const EdgeInsets.only(right: 4),
child: ProductNameConflictMarker(
onTap: () => _resolveNameIssues(focusProductId: product.id),
),
),
Text(
formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium,
),
],
),
onTap: _editing ? null : () => _openProduct(product),
);
},
);
}
}