feat: Introduce SearchableCategoryField component and integrate it into admin panels for enhanced category selection
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 12:20:57 +02:00
parent f132983b75
commit 3ad6cfee50
5 changed files with 233 additions and 103 deletions
+17 -16
View File
@@ -12,13 +12,6 @@ const _profileHeaderDestination = _AppDestination(
label: 'Profil', label: 'Profil',
); );
const _adminHeaderDestination = _AppDestination(
path: '/admin',
title: 'Admin',
icon: Icons.admin_panel_settings_outlined,
label: 'Admin',
);
class AppShell extends ConsumerWidget { class AppShell extends ConsumerWidget {
final String location; final String location;
final ValueChanged<String> onNavigateToPath; final ValueChanged<String> onNavigateToPath;
@@ -64,9 +57,7 @@ class AppShell extends ConsumerWidget {
), ),
]; ];
List<_AppDestination> _destinations(bool isAdmin) => isAdmin List<_AppDestination> _destinations() => _baseDestinations;
? [..._baseDestinations, _adminHeaderDestination]
: _baseDestinations;
int? _selectedIndex(List<_AppDestination> destinations) { int? _selectedIndex(List<_AppDestination> destinations) {
final index = destinations.indexWhere( final index = destinations.indexWhere(
@@ -77,14 +68,10 @@ class AppShell extends ConsumerWidget {
_AppDestination _selectedHeaderDestination( _AppDestination _selectedHeaderDestination(
List<_AppDestination> destinations, List<_AppDestination> destinations,
bool isAdmin,
) { ) {
if (location.startsWith('/profile')) { if (location.startsWith('/profile')) {
return _profileHeaderDestination; return _profileHeaderDestination;
} }
if (location.startsWith('/admin') && isAdmin) {
return _adminHeaderDestination;
}
final selectedIndex = _selectedIndex(destinations); final selectedIndex = _selectedIndex(destinations);
if (selectedIndex != null) { if (selectedIndex != null) {
return destinations[selectedIndex]; return destinations[selectedIndex];
@@ -96,9 +83,9 @@ class AppShell extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final locationUri = Uri.parse(location); final locationUri = Uri.parse(location);
final isAdmin = ref.watch(isAdminProvider); final isAdmin = ref.watch(isAdminProvider);
final dests = _destinations(isAdmin); final dests = _destinations();
final selectedIndex = _selectedIndex(dests); final selectedIndex = _selectedIndex(dests);
final selectedDestination = _selectedHeaderDestination(dests, isAdmin); final selectedDestination = _selectedHeaderDestination(dests);
final isWide = MediaQuery.of(context).size.width >= 900; final isWide = MediaQuery.of(context).size.width >= 900;
void navigateTo(int index) { void navigateTo(int index) {
@@ -210,6 +197,11 @@ class AppShell extends ConsumerWidget {
onNavigateToPath('/profile'); onNavigateToPath('/profile');
} }
break; break;
case 'admin':
if (location != '/admin' && context.mounted) {
onNavigateToPath('/admin');
}
break;
case 'logout': case 'logout':
await ref.read(authStateProvider.notifier).logout(); await ref.read(authStateProvider.notifier).logout();
if (context.mounted) { if (context.mounted) {
@@ -227,6 +219,15 @@ class AppShell extends ConsumerWidget {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
), ),
if (isAdmin)
PopupMenuItem<String>(
value: 'admin',
child: ListTile(
leading: Icon(Icons.admin_panel_settings_outlined),
title: Text('Admin'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuDivider(), PopupMenuDivider(),
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'logout', value: 'logout',
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
typedef CategorySelectOption = ({String value, String label});
class SearchableCategoryField extends StatefulWidget {
final List<CategorySelectOption> options;
final String? value;
final String label;
final ValueChanged<String?> onChanged;
const SearchableCategoryField({
super.key,
required this.options,
required this.value,
required this.label,
required this.onChanged,
});
@override
State<SearchableCategoryField> createState() => _SearchableCategoryFieldState();
}
class _SearchableCategoryFieldState extends State<SearchableCategoryField> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
String? _labelForValue(String? value) {
if (value == null) return null;
for (final option in widget.options) {
if (option.value == value) return option.label;
}
return null;
}
@override
void initState() {
super.initState();
_controller = TextEditingController(text: _labelForValue(widget.value) ?? '');
_focusNode = FocusNode();
}
@override
void didUpdateWidget(covariant SearchableCategoryField oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value || oldWidget.options != widget.options) {
final label = _labelForValue(widget.value) ?? '';
if (_controller.text != label) {
_controller.text = label;
}
}
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawAutocomplete<CategorySelectOption>(
textEditingController: _controller,
focusNode: _focusNode,
displayStringForOption: (option) => option.label,
optionsBuilder: (textEditingValue) {
final query = textEditingValue.text.trim().toLowerCase();
final options = widget.options;
if (query.isEmpty) return options.take(30);
return options.where((option) => option.label.toLowerCase().contains(query)).take(30);
},
onSelected: (option) => widget.onChanged(option.value),
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) {
return TextFormField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
labelText: widget.label,
hintText: 'Sök kategori',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
);
},
optionsViewBuilder: (context, onSelected, options) {
final optionList = options.toList(growable: false);
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: SizedBox(
width: 420,
height: 240,
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: optionList.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final option = optionList[index];
return ListTile(
dense: true,
title: Text(option.label),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
);
}
}
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/ui/category_then_product_picker.dart'; import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/searchable_category_field.dart';
import '../../../core/ui/product_picker_field.dart'; import '../../../core/ui/product_picker_field.dart';
import '../data/admin_repository.dart'; import '../data/admin_repository.dart';
import '../domain/admin_category_node.dart'; import '../domain/admin_category_node.dart';
@@ -38,6 +39,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
List<AdminInventoryItem> _items = []; List<AdminInventoryItem> _items = [];
List<AdminProduct> _products = []; List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = []; List<AdminCategoryNode> _categories = [];
List<CategorySelectOption> _categoryOptions = [];
List<UserAdmin> _users = []; List<UserAdmin> _users = [];
@override @override
@@ -67,6 +69,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
_items = results[0] as List<AdminInventoryItem>; _items = results[0] as List<AdminInventoryItem>;
_products = results[1] as List<AdminProduct>; _products = results[1] as List<AdminProduct>;
_categories = results[2] as List<AdminCategoryNode>; _categories = results[2] as List<AdminCategoryNode>;
_categoryOptions = _flattenCategoryOptions(_categories);
_users = results[3] as List<UserAdmin>; _users = results[3] as List<UserAdmin>;
}); });
} catch (e) { } catch (e) {
@@ -93,6 +96,20 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
_InventorySort.quantityDesc => 'Mängd fallande', _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 { List<AdminInventoryItem> get _filtered {
final q = _search.trim().toLowerCase(); final q = _search.trim().toLowerCase();
if (q.isEmpty) return _items; if (q.isEmpty) return _items;
@@ -803,6 +820,20 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
super.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) { AdminProduct? _productById(int? id) {
if (id == null) return null; if (id == null) return null;
for (final product in widget.products) { for (final product in widget.products) {
@@ -829,16 +860,7 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
preselectedCategoryId: _categoryId, preselectedCategoryId: _categoryId,
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
setState(() { _applyCategorySelection(selected.id, selected.path);
_categoryId = selected.id;
_categoryPath = selected.path;
if (_productId != null) {
final current = _productById(_productId);
if (current?.categoryId != _categoryId) {
_productId = null;
}
}
});
} }
@override @override
@@ -888,19 +910,17 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
GestureDetector( SearchableCategoryField(
onTap: _pickCategory, options: _categoryOptions,
child: InputDecorator( value: _categoryId?.toString(),
decoration: const InputDecoration( label: 'Kategori (sökbar)',
labelText: 'Kategori', onChanged: (value) {
border: OutlineInputBorder(), if (value == null) return;
), final label = _categoryOptions
child: Text( .firstWhere((option) => option.value == value)
_categoryPath == null || _categoryPath!.trim().isEmpty .label;
? 'Tryck för att välja kategori' _applyCategorySelection(int.parse(value), label);
: _categoryPath!, },
),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
@@ -5,6 +5,7 @@ import '../../../core/api/api_error_mapper.dart';
import '../../../core/forms/form_options.dart'; import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart'; import '../../../core/l10n/l10n.dart';
import '../../../core/ui/category_then_product_picker.dart'; import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/searchable_category_field.dart';
import '../../../core/ui/product_picker_field.dart'; import '../../../core/ui/product_picker_field.dart';
import '../data/admin_repository.dart'; import '../data/admin_repository.dart';
import '../domain/admin_category_node.dart'; import '../domain/admin_category_node.dart';
@@ -29,6 +30,7 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
List<AdminPantryItem> _items = []; List<AdminPantryItem> _items = [];
List<AdminProduct> _products = []; List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = []; List<AdminCategoryNode> _categories = [];
List<CategorySelectOption> _categoryOptions = [];
List<UserAdmin> _users = []; List<UserAdmin> _users = [];
@override @override
@@ -55,6 +57,7 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
_items = results[0] as List<AdminPantryItem>; _items = results[0] as List<AdminPantryItem>;
_products = results[1] as List<AdminProduct>; _products = results[1] as List<AdminProduct>;
_categories = results[2] as List<AdminCategoryNode>; _categories = results[2] as List<AdminCategoryNode>;
_categoryOptions = _flattenCategoryOptions(_categories);
_users = results[3] as List<UserAdmin>; _users = results[3] as List<UserAdmin>;
}); });
} catch (e) { } catch (e) {
@@ -77,6 +80,20 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
}).toList(); }).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;
}
Future<void> _addItem() async { Future<void> _addItem() async {
final values = await _showPantryFormDialog(initialOwnerUserId: _selectedUserId); final values = await _showPantryFormDialog(initialOwnerUserId: _selectedUserId);
if (values == null) return; if (values == null) return;
@@ -513,6 +530,20 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
super.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) { AdminProduct? _productById(int? id) {
if (id == null) return null; if (id == null) return null;
for (final product in widget.products) { for (final product in widget.products) {
@@ -539,16 +570,7 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
preselectedCategoryId: _categoryId, preselectedCategoryId: _categoryId,
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
setState(() { _applyCategorySelection(selected.id, selected.path);
_categoryId = selected.id;
_categoryPath = selected.path;
if (_productId != null) {
final current = _productById(_productId);
if (current?.categoryId != _categoryId) {
_productId = null;
}
}
});
} }
@override @override
@@ -585,19 +607,17 @@ class _PantryFormDialogState extends State<_PantryFormDialog> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
GestureDetector( SearchableCategoryField(
onTap: _pickCategory, options: _categoryOptions,
child: InputDecorator( value: _categoryId?.toString(),
decoration: const InputDecoration( label: 'Kategori (sökbar)',
labelText: 'Kategori', onChanged: (value) {
border: OutlineInputBorder(), if (value == null) return;
), final label = _categoryOptions
child: Text( .firstWhere((option) => option.value == value)
_categoryPath == null || _categoryPath!.trim().isEmpty .label;
? 'Tryck för att välja kategori' _applyCategorySelection(int.parse(value), label);
: _categoryPath!, },
),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart'; import '../../../core/l10n/l10n.dart';
import '../../../core/ui/searchable_category_field.dart';
import '../data/admin_repository.dart'; import '../data/admin_repository.dart';
import '../domain/admin_ai_categorize_result.dart'; import '../domain/admin_ai_categorize_result.dart';
import '../domain/admin_category_node.dart'; import '../domain/admin_category_node.dart';
@@ -102,6 +103,13 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
return result; return result;
} }
List<CategorySelectOption> _categoryOptionItems() {
return [
const (value: '__remove__', label: 'Ingen kategori'),
..._cachedCategoryOptions.map((option) => (value: option.value, label: option.label)),
];
}
Future<void> _applyBulkCategory() async { Future<void> _applyBulkCategory() async {
if (_selectedIds.isEmpty || _isApplying) return; if (_selectedIds.isEmpty || _isApplying) return;
if (_bulkCategoryValue == null) { if (_bulkCategoryValue == null) {
@@ -525,7 +533,6 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final categoryOptions = _cachedCategoryOptions;
final filtered = _products.where((product) { final filtered = _products.where((product) {
if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) { if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) {
return false; return false;
@@ -647,24 +654,10 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
if (!_showDeletedOnly) if (!_showDeletedOnly)
SizedBox( SizedBox(
width: 260, width: 260,
child: DropdownButtonFormField<String>( child: SearchableCategoryField(
initialValue: _bulkCategoryValue, options: _categoryOptionItems(),
decoration: InputDecoration( value: _bulkCategoryValue,
labelText: context.l10n.adminBulkSetCategory, label: context.l10n.adminBulkSetCategory,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<String>(
value: '__remove__',
child: Text(context.l10n.adminRemoveCategory),
),
...categoryOptions.map(
(option) => DropdownMenuItem<String>(
value: option.value,
child: Text(option.label),
),
),
],
onChanged: (value) => setState(() => _bulkCategoryValue = value), onChanged: (value) => setState(() => _bulkCategoryValue = value),
), ),
), ),
@@ -769,28 +762,11 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
children: [ children: [
SizedBox( SizedBox(
width: 280, width: 280,
child: DropdownButtonFormField<String>( child: SearchableCategoryField(
key: ValueKey( key: ValueKey('row-category-${product.id}-${_rowCategoryFor(product)}'),
'row-category-${product.id}-${_rowCategoryFor(product)}', options: _categoryOptionItems(),
), value: _rowCategoryFor(product),
initialValue: _rowCategoryFor(product), label: context.l10n.adminInlineCategory,
decoration: InputDecoration(
labelText: context.l10n.adminInlineCategory,
border: const OutlineInputBorder(),
isDense: true,
),
items: [
DropdownMenuItem<String>(
value: '__remove__',
child: Text(context.l10n.adminNoCategory),
),
...categoryOptions.map(
(option) => DropdownMenuItem<String>(
value: option.value,
child: Text(option.label),
),
),
],
onChanged: (value) { onChanged: (value) {
if (value == null) return; if (value == null) return;
setState(() => _rowCategoryValue[product.id] = value); setState(() => _rowCategoryValue[product.id] = value);