256 lines
8.2 KiB
Dart
256 lines
8.2 KiB
Dart
/// 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});
|
||
|
||
final ProductRepository repository;
|
||
|
||
@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 = _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: _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),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|