feat: Implement caching for selectable products and enhance product filtering in admin panels
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 18:42:35 +02:00
parent d75fd00666
commit a4f65c6065
10 changed files with 481 additions and 79 deletions
@@ -7,6 +7,7 @@ import '../../../core/l10n/l10n.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/searchable_category_field.dart';
import '../../../core/ui/product_picker_field.dart';
import 'admin_form_shared.dart';
import '../data/admin_repository.dart';
import '../domain/admin_category_node.dart';
import '../domain/admin_pantry_item.dart';
@@ -30,7 +31,6 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
List<AdminPantryItem> _items = [];
List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = [];
List<CategorySelectOption> _categoryOptions = [];
List<UserAdmin> _users = [];
@override
@@ -48,7 +48,7 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
try {
final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId),
ref.read(adminRepositoryProvider).listGlobalProducts(),
ref.read(adminRepositoryProvider).listSelectableProductsForAdmin(),
ref.read(adminRepositoryProvider).listCategoryTree(),
ref.read(adminRepositoryProvider).listUsers(),
]);
@@ -57,7 +57,6 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
_items = results[0] as List<AdminPantryItem>;
_products = results[1] as List<AdminProduct>;
_categories = results[2] as List<AdminCategoryNode>;
_categoryOptions = _flattenCategoryOptions(_categories);
_users = results[3] as List<UserAdmin>;
});
} catch (e) {
@@ -80,18 +79,8 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
}).toList();
}
List<CategorySelectOption> _flattenCategoryOptions(
List<AdminCategoryNode> nodes, [
List<String> parents = const [],
]) {
final result = <CategorySelectOption>[];
for (final node in nodes) {
final pathParts = [...parents, node.name];
final path = pathParts.join(' > ');
result.add((value: node.id.toString(), label: path));
result.addAll(_flattenCategoryOptions(node.children, pathParts));
}
return result;
List<String> get _locationOptions {
return buildLocationOptionsFromValues(_items.map((item) => item.location));
}
Future<void> _addItem() async {
@@ -152,6 +141,7 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
users: _users,
products: _products,
categories: _categories,
locationOptions: _locationOptions,
initial: initial,
initialOwnerUserId: initialOwnerUserId,
),
@@ -427,11 +417,15 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
itemBuilder: (context, index) {
final item = filtered[index];
return ListTile(
title: Text(item.displayName),
title: Row(
children: [
Expanded(child: Text(item.displayName)),
buildCategoryPathChip(item.categoryPath),
],
),
subtitle: Text(
'${item.username} (${item.userEmail})'
'${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}'
'${item.categoryPath == null || item.categoryPath!.trim().isEmpty ? '' : ' · ${item.categoryPath}'}',
'${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -489,6 +483,7 @@ class _PantryFormDialog extends StatefulWidget {
final List<UserAdmin> users;
final List<AdminProduct> products;
final List<AdminCategoryNode> categories;
final List<String> locationOptions;
final AdminPantryItem? initial;
final int? initialOwnerUserId;
@@ -496,6 +491,7 @@ class _PantryFormDialog extends StatefulWidget {
required this.users,
required this.products,
required this.categories,
required this.locationOptions,
this.initial,
this.initialOwnerUserId,
});
@@ -505,6 +501,8 @@ class _PantryFormDialog extends StatefulWidget {
}
class _PantryFormDialogState extends State<_PantryFormDialog> {
static const String _manualLocationValue = '__manual_location__';
late final TextEditingController _locationController;
late final TextEditingController _categorySearchController;
late List<CategorySelectOption> _categoryOptions;
@@ -514,6 +512,8 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
int? _categoryId;
String? _categoryPath;
String? _productErrorText;
bool _useManualLocation = false;
ProductScopeFilter _productScopeFilter = ProductScopeFilter.globalOnly;
@override
void initState() {
@@ -527,6 +527,9 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
_locationController = TextEditingController(text: initial?.location ?? '');
_categorySearchController = TextEditingController(text: _categoryPath ?? '');
_categoryOptions = _flattenCategoryOptions(widget.categories);
final initialLocation = _locationController.text.trim();
_useManualLocation = initialLocation.isNotEmpty &&
!widget.locationOptions.contains(initialLocation);
}
@override
@@ -555,18 +558,39 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
for (final product in widget.products) {
if (product.id == id) return product;
}
final initial = widget.initial;
if (initial != null && initial.productId == id) {
return AdminProduct(
id: initial.productId,
name: initial.productName,
canonicalName: initial.productCanonicalName,
ownerId: initial.userId,
categoryId: initial.categoryId,
categoryPath: initial.categoryPath,
status: 'private',
);
}
return null;
}
List<ProductOption> _productOptions() {
final source = _categoryId == null
? widget.products
: widget.products.where((p) => p.categoryId == _categoryId).toList();
final sorted = [...source]
..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
return sorted
.map((p) => (id: p.id, name: p.displayName, categoryId: p.categoryId))
.toList();
final filtered = filterSelectableAdminProducts(
products: widget.products,
ownerUserId: _ownerUserId,
categoryId: _categoryId,
scopeFilter: _productScopeFilter,
selectedProduct: _productById(_productId),
);
return toProductOptions(filtered);
}
String? _locationDropdownValue() {
return resolveLocationDropdownValue(
useManualLocation: _useManualLocation,
currentValue: _locationController.text,
options: widget.locationOptions,
manualLocationValue: _manualLocationValue,
);
}
List<CategorySelectOption> _flattenCategoryOptions(
@@ -616,11 +640,25 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
),
))
.toList(),
onChanged: (value) => setState(() => _ownerUserId = value),
onChanged: (value) => setState(() {
_ownerUserId = value;
if (value == null) {
_productScopeFilter = ProductScopeFilter.globalOnly;
final selected = _productById(_productId);
if (selected?.ownerId != null) {
_productId = null;
}
}
}),
decoration: const InputDecoration(labelText: 'Ägare (användare)'),
),
const SizedBox(height: 12),
] else ...[
Text(
'Produkt: ${widget.initial!.displayName}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Ägare: ${widget.initial!.username} (${widget.initial!.userEmail})',
style: Theme.of(context).textTheme.bodyMedium,
@@ -673,11 +711,73 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
});
},
),
const SizedBox(height: 12),
TextFormField(
controller: _locationController,
if (_ownerUserId != null) ...[
const SizedBox(height: 8),
SegmentedButton<ProductScopeFilter>(
segments: const [
ButtonSegment<ProductScopeFilter>(
value: ProductScopeFilter.all,
label: Text('Alla'),
),
ButtonSegment<ProductScopeFilter>(
value: ProductScopeFilter.globalOnly,
label: Text('Globala'),
),
ButtonSegment<ProductScopeFilter>(
value: ProductScopeFilter.privateOnly,
label: Text('Privata'),
),
],
selected: {_productScopeFilter},
onSelectionChanged: (selection) {
if (selection.isEmpty) return;
setState(() => _productScopeFilter = selection.first);
},
),
const SizedBox(height: 12),
] else ...[
const SizedBox(height: 12),
],
DropdownButtonFormField<String>(
initialValue: _locationDropdownValue(),
decoration: const InputDecoration(labelText: 'Plats (valfritt)'),
items: [
...widget.locationOptions.map(
(option) => DropdownMenuItem<String>(
value: option,
child: Text(option),
),
),
const DropdownMenuItem<String>(
value: _manualLocationValue,
child: Text('Ange manuellt...'),
),
],
onChanged: (value) {
if (value == null) {
setState(() {
_useManualLocation = false;
_locationController.clear();
});
return;
}
if (value == _manualLocationValue) {
setState(() => _useManualLocation = true);
return;
}
setState(() {
_useManualLocation = false;
_locationController.text = value;
});
},
),
if (_useManualLocation) ...[
const SizedBox(height: 12),
TextFormField(
controller: _locationController,
decoration: const InputDecoration(labelText: 'Plats (manuell)'),
),
],
],
),
),
@@ -714,4 +814,4 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
],
);
}
}
}