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
@@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.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_inventory_item.dart';
@@ -39,7 +40,6 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
List<AdminInventoryItem> _items = [];
List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = [];
List<CategorySelectOption> _categoryOptions = [];
List<UserAdmin> _users = [];
@override
@@ -60,7 +60,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
userId: _selectedUserId,
sort: _sortParam,
),
ref.read(adminRepositoryProvider).listGlobalProducts(),
ref.read(adminRepositoryProvider).listSelectableProductsForAdmin(),
ref.read(adminRepositoryProvider).listCategoryTree(),
ref.read(adminRepositoryProvider).listUsers(),
]);
@@ -69,7 +69,6 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
_items = results[0] as List<AdminInventoryItem>;
_products = results[1] as List<AdminProduct>;
_categories = results[2] as List<AdminCategoryNode>;
_categoryOptions = _flattenCategoryOptions(_categories);
_users = results[3] as List<UserAdmin>;
});
} catch (e) {
@@ -96,20 +95,6 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
_InventorySort.quantityDesc => 'Mängd fallande',
};
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<AdminInventoryItem> get _filtered {
final q = _search.trim().toLowerCase();
if (q.isEmpty) return _items;
@@ -122,6 +107,10 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
}).toList();
}
List<String> get _locationOptions {
return buildLocationOptionsFromValues(_items.map((item) => item.location));
}
Future<void> _addItem() async {
final values = await _showInventoryFormDialog(initialOwnerUserId: _selectedUserId);
if (values == null) return;
@@ -478,6 +467,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
users: _users,
products: _products,
categories: _categories,
locationOptions: _locationOptions,
initial: initial,
initialOwnerUserId: initialOwnerUserId,
),
@@ -678,11 +668,15 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
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.quantity} ${item.unit} · ${item.username} (${item.userEmail})'
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}'
'${item.categoryPath == null || item.categoryPath!.isEmpty ? '' : ' · ${item.categoryPath}'}',
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -757,6 +751,7 @@ class _InventoryFormDialog extends StatefulWidget {
final List<UserAdmin> users;
final List<AdminProduct> products;
final List<AdminCategoryNode> categories;
final List<String> locationOptions;
final AdminInventoryItem? initial;
final int? initialOwnerUserId;
@@ -764,6 +759,7 @@ class _InventoryFormDialog extends StatefulWidget {
required this.users,
required this.products,
required this.categories,
required this.locationOptions,
this.initial,
this.initialOwnerUserId,
});
@@ -773,6 +769,8 @@ class _InventoryFormDialog extends StatefulWidget {
}
class _InventoryFormDialogState extends State<_InventoryFormDialog> {
static const String _manualLocationValue = '__manual_location__';
final _formKey = GlobalKey<FormState>();
late final TextEditingController _quantityController;
late final TextEditingController _unitController;
@@ -789,6 +787,8 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
int? _categoryId;
String? _categoryPath;
String? _productErrorText;
bool _useManualLocation = false;
ProductScopeFilter _productScopeFilter = ProductScopeFilter.globalOnly;
@override
void initState() {
@@ -810,6 +810,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
_commentController = TextEditingController(text: initial?.comment ?? '');
_categorySearchController = TextEditingController(text: _categoryPath ?? '');
_categoryOptions = _flattenCategoryOptions(widget.categories);
final initialLocation = _locationController.text.trim();
_useManualLocation = initialLocation.isNotEmpty &&
!widget.locationOptions.contains(initialLocation);
}
@override
@@ -844,18 +847,39 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
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(
@@ -914,12 +938,26 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
),
))
.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)'),
validator: (value) => value == null ? 'Välj användare' : null,
),
const SizedBox(height: 12),
] else ...[
Text(
'Produkt: ${widget.initial!.displayName}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
@@ -975,7 +1013,33 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
});
},
),
const SizedBox(height: 12),
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),
],
TextFormField(
controller: _quantityController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
@@ -994,10 +1058,46 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
(value == null || value.trim().isEmpty) ? 'Ange enhet' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _locationController,
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)'),
),
],
const SizedBox(height: 12),
TextFormField(
controller: _brandController,