feat: Implement admin pantry item management with create and update functionality, including category selection and validation
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 11:18:13 +02:00
parent 573c12cdc3
commit f132983b75
11 changed files with 683 additions and 38 deletions
@@ -3,7 +3,10 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/product_picker_field.dart';
import '../data/admin_repository.dart';
import '../domain/admin_category_node.dart';
import '../domain/admin_inventory_item.dart';
import '../domain/admin_product.dart';
import '../domain/user_admin.dart';
@@ -34,6 +37,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
_InventorySort _sort = _InventorySort.newest;
List<AdminInventoryItem> _items = [];
List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = [];
List<UserAdmin> _users = [];
@override
@@ -55,13 +59,15 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
sort: _sortParam,
),
ref.read(adminRepositoryProvider).listGlobalProducts(),
ref.read(adminRepositoryProvider).listCategoryTree(),
ref.read(adminRepositoryProvider).listUsers(),
]);
if (!mounted) return;
setState(() {
_items = results[0] as List<AdminInventoryItem>;
_products = results[1] as List<AdminProduct>;
_users = results[2] as List<UserAdmin>;
_categories = results[2] as List<AdminCategoryNode>;
_users = results[3] as List<UserAdmin>;
});
} catch (e) {
if (!mounted) return;
@@ -94,7 +100,8 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
return item.displayName.toLowerCase().contains(q) ||
item.username.toLowerCase().contains(q) ||
item.userEmail.toLowerCase().contains(q) ||
(item.location ?? '').toLowerCase().contains(q);
(item.location ?? '').toLowerCase().contains(q) ||
(item.categoryPath ?? '').toLowerCase().contains(q);
}).toList();
}
@@ -453,6 +460,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
builder: (context) => _InventoryFormDialog(
users: _users,
products: _products,
categories: _categories,
initial: initial,
initialOwnerUserId: initialOwnerUserId,
),
@@ -656,7 +664,8 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
title: Text(item.displayName),
subtitle: Text(
'${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})'
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}',
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}'
'${item.categoryPath == null || item.categoryPath!.isEmpty ? '' : ' · ${item.categoryPath}'}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -730,12 +739,14 @@ class _InventoryFormValues {
class _InventoryFormDialog extends StatefulWidget {
final List<UserAdmin> users;
final List<AdminProduct> products;
final List<AdminCategoryNode> categories;
final AdminInventoryItem? initial;
final int? initialOwnerUserId;
const _InventoryFormDialog({
required this.users,
required this.products,
required this.categories,
this.initial,
this.initialOwnerUserId,
});
@@ -756,6 +767,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
int? _ownerUserId;
int? _productId;
int? _categoryId;
String? _categoryPath;
String? _productErrorText;
@override
void initState() {
@@ -763,6 +777,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
final initial = widget.initial;
_ownerUserId = initial?.userId ?? widget.initialOwnerUserId;
_productId = initial?.productId;
final initialProduct = _productById(_productId);
_categoryId = initialProduct?.categoryId;
_categoryPath = initialProduct?.categoryPath;
_quantityController = TextEditingController(
text: initial == null ? '' : initial.quantity.toString(),
);
@@ -786,6 +803,44 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
super.dispose();
}
AdminProduct? _productById(int? id) {
if (id == null) return null;
for (final product in widget.products) {
if (product.id == id) return product;
}
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();
}
Future<void> _pickCategory() async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: widget.categories,
preselectedCategoryId: _categoryId,
);
if (selected == null || !mounted) return;
setState(() {
_categoryId = selected.id;
_categoryPath = selected.path;
if (_productId != null) {
final current = _productById(_productId);
if (current?.categoryId != _categoryId) {
_productId = null;
}
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
@@ -833,20 +888,53 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
),
const SizedBox(height: 12),
],
DropdownButtonFormField<int>(
initialValue: _productId,
items: widget.products
.map((p) => DropdownMenuItem<int>(
value: p.id,
child: Text(
p.displayName,
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (value) => setState(() => _productId = value),
decoration: const InputDecoration(labelText: 'Produkt'),
validator: (value) => value == null ? 'Välj en produkt' : null,
GestureDetector(
onTap: _pickCategory,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Kategori',
border: OutlineInputBorder(),
),
child: Text(
_categoryPath == null || _categoryPath!.trim().isEmpty
? 'Tryck för att välja kategori'
: _categoryPath!,
),
),
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
onPressed: _pickCategory,
icon: const Icon(Icons.category_outlined),
label: const Text('Välj kategori'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
setState(() {
_categoryId = null;
_categoryPath = null;
});
},
icon: const Icon(Icons.clear),
label: const Text('Rensa kategori'),
),
],
),
const SizedBox(height: 12),
ProductPickerField(
products: _productOptions(),
value: _productId,
label: 'Produkt',
errorText: _productErrorText,
onChanged: (value) {
setState(() {
_productId = value;
_productErrorText = null;
});
},
),
const SizedBox(height: 12),
TextFormField(
@@ -905,6 +993,10 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
FilledButton(
onPressed: () {
if (!_formKey.currentState!.validate()) return;
if (_productId == null) {
setState(() => _productErrorText = 'Välj en produkt');
return;
}
final quantity =
double.parse(_quantityController.text.trim().replaceAll(',', '.'));