added categories, import export, settings

This commit is contained in:
davmar
2026-08-29 16:45:27 +02:00
parent 2a5a922aab
commit 732a0dc14a
30 changed files with 3700 additions and 167 deletions
+219 -65
View File
@@ -1,100 +1,254 @@
/// Confirmed products stored on device.
/// Catalog of products from confirmed receipts.
library;
import 'package:flutter/material.dart';
import 'models/product.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/product_image.dart';
import 'widgets/settings_button.dart';
import 'widgets/store_tag.dart';
class ProductsScreen extends StatefulWidget {
const ProductsScreen({
super.key,
required this.repository,
this.savedCount,
});
const ProductsScreen({super.key, required this.repository});
final ProductRepository repository;
final int? savedCount;
@override
State<ProductsScreen> createState() => _ProductsScreenState();
}
class _ProductsScreenState extends State<ProductsScreen> {
ProductSort _sort = ProductSort.preferred;
bool _favouritesOnly = false;
bool _searching = false;
final TextEditingController _search = TextEditingController();
late Future<List<Product>> _future;
@override
void initState() {
super.initState();
_future = widget.repository.getAll();
final saved = widget.savedCount;
if (saved != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
saved == 1
? 'Saved 1 product.'
: 'Saved $saved products.',
),
),
);
});
_future = _load();
_search.addListener(() => setState(() {}));
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<List<Product>> _load() {
return widget.repository.getAll(
sort: _sort,
favouritesOnly: _favouritesOnly,
);
}
void _reload() {
setState(() => _future = _load());
}
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) _reload();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: 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 = snapshot.data!;
if (products.isEmpty) {
return Center(
child: Text(
'Confirmed receipt items will show up here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: products.length,
separatorBuilder: (_, _) => const Divider(indent: 16, endIndent: 16),
itemBuilder: (context, index) {
final product = products[index];
final seen = product.timesSeen;
return ListTile(
title: Text(product.name),
subtitle: Text(seen == 1 ? 'Seen once' : 'Seen $seen times'),
trailing: Text(
formatEuro(product.lastPrice),
style: theme.textTheme.titleMedium,
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: [
IconButton(
tooltip: _searching ? 'Close search' : 'Search',
onPressed: () {
setState(() {
_searching = !_searching;
if (!_searching) _search.clear();
});
},
);
},
icon: Icon(_searching ? Icons.close : Icons.search),
),
const 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('AZ'),
),
],
selected: {_sort},
onSelectionChanged: (selected) {
setState(() {
_sort = selected.first;
_future = _load();
});
},
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Favourites',
isSelected: _favouritesOnly,
onPressed: () {
setState(() {
_favouritesOnly = !_favouritesOnly;
_future = _load();
});
},
icon: Icon(
_favouritesOnly ? Icons.star : Icons.star_outline,
),
),
],
),
),
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),
);
},
);
},
),
),
],
),
);
}