Files
recipe-app/flutter/lib/features/admin/presentation/admin_pantry_panel.dart
T
Nils-Johan Gynther d05b7da8bc
Test Suite / test (24.15.0) (push) Has been cancelled
feat: Add isPrivate field to AdminProduct and update filtering logic in admin panels
2026-05-11 19:30:42 +02:00

819 lines
29 KiB
Dart

import 'package:flutter/material.dart';
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/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';
import '../domain/admin_product.dart';
import '../domain/user_admin.dart';
class AdminPantryPanel extends ConsumerStatefulWidget {
final bool embedded;
const AdminPantryPanel({super.key, this.embedded = false});
@override
ConsumerState<AdminPantryPanel> createState() => _AdminPantryPanelState();
}
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
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId),
ref.read(adminRepositoryProvider).listSelectableProductsForAdmin(),
ref.read(adminRepositoryProvider).listCategoryTree(),
ref.read(adminRepositoryProvider).listUsers(),
]);
if (!mounted) return;
setState(() {
_items = results[0] as List<AdminPantryItem>;
_products = results[1] as List<AdminProduct>;
_categories = results[2] as List<AdminCategoryNode>;
_users = results[3] as List<UserAdmin>;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
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();
}
List<String> get _locationOptions {
return buildLocationOptionsFromValues(_items.map((item) => item.location));
}
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,
locationOptions: _locationOptions,
initial: initial,
initialOwnerUserId: initialOwnerUserId,
),
);
}
Future<void> _moveToInventory(AdminPantryItem item) async {
final quantityController = TextEditingController(text: '1');
String selectedUnit = 'st';
String? selectedLocation;
String? formError;
final payload = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setDialogState) {
return AlertDialog(
title: Text(context.l10n.pantryAddToInventoryTitle(item.displayName)),
content: SizedBox(
width: 380,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: quantityController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: context.l10n.inventoryQuantityDisplayLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedUnit,
isExpanded: true,
decoration: InputDecoration(
labelText: context.l10n.unitLabel,
border: const OutlineInputBorder(),
),
items: unitOptions
.map((option) => DropdownMenuItem<String>(
value: option.value,
child: Text(option.label),
))
.toList(),
onChanged: (value) {
if (value == null) return;
setDialogState(() => selectedUnit = value);
},
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedLocation,
isExpanded: true,
decoration: InputDecoration(
labelText: context.l10n.locationOptionalLabel,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<String>(
value: null,
child: Text(context.l10n.pantryNoLocation),
),
...inventoryLocationOptions.map(
(location) => DropdownMenuItem<String>(
value: location,
child: Text(location),
),
),
],
onChanged: (value) {
setDialogState(() => selectedLocation = value);
},
),
if (formError != null) ...[
const SizedBox(height: 8),
Text(
formError!,
style: TextStyle(color: Theme.of(ctx).colorScheme.error),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () {
final quantity = double.tryParse(
quantityController.text.trim().replaceAll(',', '.'),
);
if (quantity == null || quantity <= 0) {
setDialogState(() {
formError = context.l10n.pantryInvalidQuantity;
});
return;
}
Navigator.pop(ctx, {
'quantity': quantity,
'unit': selectedUnit,
'location': selectedLocation,
});
},
child: Text(context.l10n.addAction),
),
],
);
},
);
},
);
quantityController.dispose();
if (payload == null) return;
try {
await ref.read(adminRepositoryProvider).moveAdminPantryToInventory(
item.id,
{
'productId': item.productId,
'quantity': payload['quantity'] as double,
'unit': payload['unit'] as String,
if (payload['location'] != null) 'location': payload['location'] as String,
},
);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Flyttade "${item.displayName}" till inventarie.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_error != null) {
return buildCopyableErrorPanel(
context: context,
message: _error!,
onRetry: _load,
title: 'Kunde inte läsa admin pantry',
);
}
final filtered = _filtered;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Baslager', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'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),
const Wrap(
spacing: 8,
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')),
],
),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: DropdownButtonFormField<int>(
initialValue: _selectedUserId,
decoration: const InputDecoration(labelText: 'Filtrera användare'),
items: [
const DropdownMenuItem<int>(
value: null,
child: Text('Alla användare'),
),
..._users.map(
(user) => DropdownMenuItem<int>(
value: user.id,
child: Text('${user.displayName} (${user.username})'),
),
),
],
onChanged: (value) {
setState(() => _selectedUserId = value);
_load();
},
),
),
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: filtered.isEmpty
? Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Inga pantry-poster hittades.',
style: theme.textTheme.bodyMedium,
),
],
),
),
)
: ListView.separated(
itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = filtered[index];
return ListTile(
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}'}',
),
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),
onPressed: () => _moveToInventory(item),
),
IconButton(
tooltip: 'Ta bort',
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
try {
await ref.read(adminRepositoryProvider).removeAdminPantryItem(item.id);
if (!mounted) return;
await _load();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
},
),
],
),
);
},
),
),
],
);
}
}
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 List<String> locationOptions;
final AdminPantryItem? initial;
final int? initialOwnerUserId;
const _PantryFormDialog({
required this.users,
required this.products,
required this.categories,
required this.locationOptions,
this.initial,
this.initialOwnerUserId,
});
@override
State<_PantryFormDialog> createState() => _PantryFormDialogState();
}
class _PantryFormDialogState extends State<_PantryFormDialog> {
static const String _manualLocationValue = '__manual_location__';
late final TextEditingController _locationController;
late final TextEditingController _categorySearchController;
late List<CategorySelectOption> _categoryOptions;
int? _ownerUserId;
int? _productId;
int? _categoryId;
String? _categoryPath;
String? _productErrorText;
bool _useManualLocation = false;
ProductScopeFilter _productScopeFilter = ProductScopeFilter.globalOnly;
@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 ?? '');
_categorySearchController = TextEditingController(text: _categoryPath ?? '');
_categoryOptions = _flattenCategoryOptions(widget.categories);
final initialLocation = _locationController.text.trim();
_useManualLocation = initialLocation.isNotEmpty &&
!widget.locationOptions.contains(initialLocation);
}
@override
void dispose() {
_locationController.dispose();
_categorySearchController.dispose();
super.dispose();
}
void _applyCategorySelection(int id, String path) {
setState(() {
_categoryId = id;
_categoryPath = path;
_categorySearchController.text = path;
if (_productId != null) {
final current = _productById(_productId);
if (current?.categoryId != _categoryId) {
_productId = null;
}
}
});
}
AdminProduct? _productById(int? id) {
if (id == null) return null;
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,
isPrivate: null,
categoryId: initial.categoryId,
categoryPath: initial.categoryPath,
status: 'private',
);
}
return null;
}
List<ProductOption> _productOptions() {
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(
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;
}
Future<void> _pickCategory() async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: widget.categories,
preselectedCategoryId: _categoryId,
);
if (selected == null || !mounted) return;
_applyCategorySelection(selected.id, selected.path);
}
@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;
if (value == null) {
_productScopeFilter = ProductScopeFilter.globalOnly;
final selected = _productById(_productId);
if (selected?.isPrivate == true) {
_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,
),
const SizedBox(height: 12),
],
SearchableCategoryField(
options: _categoryOptions,
value: _categoryId?.toString(),
label: 'Kategori (sökbar)',
onChanged: (value) {
if (value == null) return;
final label = _categoryOptions
.firstWhere((option) => option.value == value)
.label;
_applyCategorySelection(int.parse(value), label);
},
),
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;
});
},
),
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)'),
),
],
],
),
),
),
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'),
),
],
);
}
}