added categories, import export, settings
This commit is contained in:
+219
-65
@@ -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('A–Z'),
|
||||
),
|
||||
],
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user