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
@@ -436,6 +436,38 @@ class AdminRepository {
Future<void> removeAdminPantryItem(int pantryItemId) =>
_deleteVoid(PantryApiPaths.adminRemove(pantryItemId));
Future<AdminPantryItem> createAdminPantry({
int? userId,
required int productId,
String? location,
}) {
return _post<AdminPantryItem>(
PantryApiPaths.adminCreate,
body: {
if (userId != null) 'userId': userId,
'productId': productId,
if (location != null && location.trim().isNotEmpty)
'location': location.trim(),
},
parse: (d) => AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
);
}
Future<AdminPantryItem> updateAdminPantry(
int pantryItemId, {
int? productId,
String? location,
}) {
return _patch(
PantryApiPaths.adminUpdate(pantryItemId),
body: {
if (productId != null) 'productId': productId,
if (location != null) 'location': location,
},
parse: AdminPantryItem.fromJson,
);
}
Future<void> moveAdminPantryToInventory(
int pantryItemId,
Map<String, dynamic> body,
@@ -6,6 +6,8 @@ class AdminInventoryItem {
final int productId;
final String productName;
final String? productCanonicalName;
final int? categoryId;
final String? categoryPath;
final double quantity;
final String unit;
final String? location;
@@ -22,6 +24,8 @@ class AdminInventoryItem {
required this.productId,
required this.productName,
this.productCanonicalName,
this.categoryId,
this.categoryPath,
required this.quantity,
required this.unit,
this.location,
@@ -40,6 +44,15 @@ class AdminInventoryItem {
factory AdminInventoryItem.fromJson(Map<String, dynamic> json) {
final user = (json['user'] as Map<String, dynamic>?) ?? const {};
final product = (json['product'] as Map<String, dynamic>?) ?? const {};
final names = <String>[];
dynamic current = product['categoryRef'];
while (current is Map<String, dynamic>) {
final name = current['name']?.toString().trim();
if (name != null && name.isNotEmpty) {
names.insert(0, name);
}
current = current['parent'];
}
return AdminInventoryItem(
id: json['id'] as int,
userId: json['userId'] as int,
@@ -48,6 +61,8 @@ class AdminInventoryItem {
productId: json['productId'] as int,
productName: product['name'] as String? ?? '',
productCanonicalName: product['canonicalName'] as String?,
categoryId: (product['categoryId'] as num?)?.toInt(),
categoryPath: names.isEmpty ? null : names.join(' > '),
quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
unit: json['unit'] as String? ?? '',
location: json['location'] as String?,
@@ -6,6 +6,8 @@ class AdminPantryItem {
final int productId;
final String productName;
final String? productCanonicalName;
final int? categoryId;
final String? categoryPath;
final String? location;
const AdminPantryItem({
@@ -16,6 +18,8 @@ class AdminPantryItem {
required this.productId,
required this.productName,
this.productCanonicalName,
this.categoryId,
this.categoryPath,
this.location,
});
@@ -28,6 +32,15 @@ class AdminPantryItem {
factory AdminPantryItem.fromJson(Map<String, dynamic> json) {
final user = (json['user'] as Map<String, dynamic>?) ?? const {};
final product = (json['product'] as Map<String, dynamic>?) ?? const {};
final names = <String>[];
dynamic current = product['categoryRef'];
while (current is Map<String, dynamic>) {
final name = current['name']?.toString().trim();
if (name != null && name.isNotEmpty) {
names.insert(0, name);
}
current = current['parent'];
}
return AdminPantryItem(
id: (json['id'] as num).toInt(),
userId: (json['userId'] as num).toInt(),
@@ -36,6 +49,8 @@ class AdminPantryItem {
productId: (json['productId'] as num).toInt(),
productName: product['name'] as String? ?? '',
productCanonicalName: product['canonicalName'] as String?,
categoryId: (product['categoryId'] as num?)?.toInt(),
categoryPath: names.isEmpty ? null : names.join(' > '),
location: json['location'] as String?,
);
}
@@ -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(',', '.'));
@@ -4,8 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.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_pantry_item.dart';
import '../domain/admin_product.dart';
import '../domain/user_admin.dart';
class AdminPantryPanel extends ConsumerStatefulWidget {
@@ -20,8 +24,11 @@ class AdminPantryPanel extends ConsumerStatefulWidget {
class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
bool _isLoading = true;
String? _error;
String _search = '';
int? _selectedUserId;
List<AdminPantryItem> _items = [];
List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = [];
List<UserAdmin> _users = [];
@override
@@ -39,12 +46,16 @@ 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).listCategoryTree(),
ref.read(adminRepositoryProvider).listUsers(),
]);
if (!mounted) return;
setState(() {
_items = results[0] as List<AdminPantryItem>;
_users = results[1] as List<UserAdmin>;
_products = results[1] as List<AdminProduct>;
_categories = results[2] as List<AdminCategoryNode>;
_users = results[3] as List<UserAdmin>;
});
} catch (e) {
if (!mounted) return;
@@ -54,6 +65,82 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
}
}
List<AdminPantryItem> get _filtered {
final q = _search.trim().toLowerCase();
if (q.isEmpty) return _items;
return _items.where((item) {
return item.displayName.toLowerCase().contains(q) ||
item.username.toLowerCase().contains(q) ||
item.userEmail.toLowerCase().contains(q) ||
(item.location ?? '').toLowerCase().contains(q) ||
(item.categoryPath ?? '').toLowerCase().contains(q);
}).toList();
}
Future<void> _addItem() async {
final values = await _showPantryFormDialog(initialOwnerUserId: _selectedUserId);
if (values == null) return;
try {
await ref.read(adminRepositoryProvider).createAdminPantry(
userId: values.ownerUserId,
productId: values.productId,
location: values.location,
);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Baslager-post skapad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<void> _editItem(AdminPantryItem item) async {
final values = await _showPantryFormDialog(initial: item);
if (values == null) return;
try {
await ref.read(adminRepositoryProvider).updateAdminPantry(
item.id,
productId: values.productId,
location: values.location,
);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Baslager-post uppdaterad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<_PantryFormValues?> _showPantryFormDialog({
AdminPantryItem? initial,
int? initialOwnerUserId,
}) {
return showDialog<_PantryFormValues>(
context: context,
builder: (context) => _PantryFormDialog(
users: _users,
products: _products,
categories: _categories,
initial: initial,
initialOwnerUserId: initialOwnerUserId,
),
);
}
Future<void> _moveToInventory(AdminPantryItem item) async {
final quantityController = TextEditingController(text: '1');
String selectedUnit = 'st';
@@ -204,6 +291,8 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
);
}
final filtered = _filtered;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -216,7 +305,7 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
Text('Baslager', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Här ser du användarnas pantryposter. Flytta dem tillbaka till inventarie eller ta bort poster som inte längre ska ligga kvar.',
'Här ser du användarnas pantryposter. Du kan lägga till, ändra, flytta till inventarie och sätta/ändra kategori via produktval.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
@@ -225,6 +314,8 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
runSpacing: 8,
children: [
Chip(label: Text('User-scope')),
Chip(label: Text('Kategorier')),
Chip(label: Text('Ändra/Lägg till')),
Chip(label: Text('Flytta till inventarie')),
Chip(label: Text('Ta bort')),
],
@@ -262,18 +353,36 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Sök produkt, kategori, användare eller plats',
),
onChanged: (value) => setState(() => _search = value),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Uppdatera'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text('Lägg till'),
),
],
),
),
),
const SizedBox(height: 12),
Text('Visar ${filtered.length} av ${_items.length} baslager-poster'),
const SizedBox(height: 8),
Expanded(
child: _items.isEmpty
child: filtered.isEmpty
? Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -296,18 +405,25 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
),
)
: ListView.separated(
itemCount: _items.length,
itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = _items[index];
final item = filtered[index];
return ListTile(
title: Text(item.displayName),
subtitle: Text(
'${item.username} (${item.userEmail})${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}',
'${item.username} (${item.userEmail})'
'${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}'
'${item.categoryPath == null || item.categoryPath!.trim().isEmpty ? '' : ' · ${item.categoryPath}'}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Ändra',
icon: const Icon(Icons.edit_outlined),
onPressed: () => _editItem(item),
),
IconButton(
tooltip: 'Flytta till inventarie',
icon: const Icon(Icons.inventory_2_outlined),
@@ -338,4 +454,224 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
],
);
}
}
class _PantryFormValues {
final int? ownerUserId;
final int productId;
final String? location;
const _PantryFormValues({
this.ownerUserId,
required this.productId,
this.location,
});
}
class _PantryFormDialog extends StatefulWidget {
final List<UserAdmin> users;
final List<AdminProduct> products;
final List<AdminCategoryNode> categories;
final AdminPantryItem? initial;
final int? initialOwnerUserId;
const _PantryFormDialog({
required this.users,
required this.products,
required this.categories,
this.initial,
this.initialOwnerUserId,
});
@override
State<_PantryFormDialog> createState() => _PantryFormDialogState();
}
class _PantryFormDialogState extends State<_PantryFormDialog> {
late final TextEditingController _locationController;
int? _ownerUserId;
int? _productId;
int? _categoryId;
String? _categoryPath;
String? _productErrorText;
@override
void initState() {
super.initState();
final initial = widget.initial;
_ownerUserId = initial?.userId ?? widget.initialOwnerUserId;
_productId = initial?.productId;
final initialProduct = _productById(_productId);
_categoryId = initialProduct?.categoryId;
_categoryPath = initialProduct?.categoryPath;
_locationController = TextEditingController(text: initial?.location ?? '');
}
@override
void dispose() {
_locationController.dispose();
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(
title: Text(widget.initial == null ? 'Lägg till baslager-post' : 'Ändra baslager-post'),
content: SizedBox(
width: 460,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.initial == null) ...[
DropdownButtonFormField<int>(
initialValue: _ownerUserId,
items: widget.users
.map((u) => DropdownMenuItem<int>(
value: u.id,
child: Text(
'${u.displayName} (${u.username})',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (value) => setState(() => _ownerUserId = value),
decoration: const InputDecoration(labelText: 'Ägare (användare)'),
),
const SizedBox(height: 12),
] else ...[
Text(
'Ägare: ${widget.initial!.username} (${widget.initial!.userEmail})',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
],
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(
controller: _locationController,
decoration: const InputDecoration(labelText: 'Plats (valfritt)'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () {
if (_productId == null) {
setState(() => _productErrorText = 'Välj en produkt');
return;
}
if (widget.initial == null && _ownerUserId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Välj användare')),
);
return;
}
Navigator.of(context).pop(
_PantryFormValues(
ownerUserId: _ownerUserId,
productId: _productId!,
location: _locationController.text.trim().isEmpty
? null
: _locationController.text.trim(),
),
);
},
child: const Text('Spara'),
),
],
);
}
}