From 3ad6cfee50c79883673f78bdab647443e7369f60 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Mon, 11 May 2026 12:20:57 +0200 Subject: [PATCH] feat: Introduce SearchableCategoryField component and integrate it into admin panels for enhanced category selection --- flutter/lib/core/ui/app_shell.dart | 33 ++--- .../core/ui/searchable_category_field.dart | 113 ++++++++++++++++++ .../presentation/admin_inventory_panel.dart | 66 ++++++---- .../presentation/admin_pantry_panel.dart | 66 ++++++---- .../presentation/admin_products_panel.dart | 58 +++------ 5 files changed, 233 insertions(+), 103 deletions(-) create mode 100644 flutter/lib/core/ui/searchable_category_field.dart diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index cefde520..3e5fcb88 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -12,13 +12,6 @@ const _profileHeaderDestination = _AppDestination( label: 'Profil', ); -const _adminHeaderDestination = _AppDestination( - path: '/admin', - title: 'Admin', - icon: Icons.admin_panel_settings_outlined, - label: 'Admin', -); - class AppShell extends ConsumerWidget { final String location; final ValueChanged onNavigateToPath; @@ -64,9 +57,7 @@ class AppShell extends ConsumerWidget { ), ]; - List<_AppDestination> _destinations(bool isAdmin) => isAdmin - ? [..._baseDestinations, _adminHeaderDestination] - : _baseDestinations; + List<_AppDestination> _destinations() => _baseDestinations; int? _selectedIndex(List<_AppDestination> destinations) { final index = destinations.indexWhere( @@ -77,14 +68,10 @@ class AppShell extends ConsumerWidget { _AppDestination _selectedHeaderDestination( List<_AppDestination> destinations, - bool isAdmin, ) { if (location.startsWith('/profile')) { return _profileHeaderDestination; } - if (location.startsWith('/admin') && isAdmin) { - return _adminHeaderDestination; - } final selectedIndex = _selectedIndex(destinations); if (selectedIndex != null) { return destinations[selectedIndex]; @@ -96,9 +83,9 @@ class AppShell extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final locationUri = Uri.parse(location); final isAdmin = ref.watch(isAdminProvider); - final dests = _destinations(isAdmin); + final dests = _destinations(); final selectedIndex = _selectedIndex(dests); - final selectedDestination = _selectedHeaderDestination(dests, isAdmin); + final selectedDestination = _selectedHeaderDestination(dests); final isWide = MediaQuery.of(context).size.width >= 900; void navigateTo(int index) { @@ -210,6 +197,11 @@ class AppShell extends ConsumerWidget { onNavigateToPath('/profile'); } break; + case 'admin': + if (location != '/admin' && context.mounted) { + onNavigateToPath('/admin'); + } + break; case 'logout': await ref.read(authStateProvider.notifier).logout(); if (context.mounted) { @@ -227,6 +219,15 @@ class AppShell extends ConsumerWidget { contentPadding: EdgeInsets.zero, ), ), + if (isAdmin) + PopupMenuItem( + value: 'admin', + child: ListTile( + leading: Icon(Icons.admin_panel_settings_outlined), + title: Text('Admin'), + contentPadding: EdgeInsets.zero, + ), + ), PopupMenuDivider(), PopupMenuItem( value: 'logout', diff --git a/flutter/lib/core/ui/searchable_category_field.dart b/flutter/lib/core/ui/searchable_category_field.dart new file mode 100644 index 00000000..67e79ec1 --- /dev/null +++ b/flutter/lib/core/ui/searchable_category_field.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +typedef CategorySelectOption = ({String value, String label}); + +class SearchableCategoryField extends StatefulWidget { + final List options; + final String? value; + final String label; + final ValueChanged onChanged; + + const SearchableCategoryField({ + super.key, + required this.options, + required this.value, + required this.label, + required this.onChanged, + }); + + @override + State createState() => _SearchableCategoryFieldState(); +} + +class _SearchableCategoryFieldState extends State { + 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( + 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), + ); + }, + ), + ), + ), + ); + }, + ); + } +} diff --git a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart index 6eb03b5b..f74a64c5 100644 --- a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart @@ -4,6 +4,7 @@ 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/searchable_category_field.dart'; import '../../../core/ui/product_picker_field.dart'; import '../data/admin_repository.dart'; import '../domain/admin_category_node.dart'; @@ -38,6 +39,7 @@ class _AdminInventoryPanelState extends ConsumerState { List _items = []; List _products = []; List _categories = []; + List _categoryOptions = []; List _users = []; @override @@ -67,6 +69,7 @@ class _AdminInventoryPanelState extends ConsumerState { _items = results[0] as List; _products = results[1] as List; _categories = results[2] as List; + _categoryOptions = _flattenCategoryOptions(_categories); _users = results[3] as List; }); } catch (e) { @@ -93,6 +96,20 @@ class _AdminInventoryPanelState extends ConsumerState { _InventorySort.quantityDesc => 'Mängd fallande', }; + List _flattenCategoryOptions( + List nodes, [ + List parents = const [], + ]) { + final result = []; + 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 get _filtered { final q = _search.trim().toLowerCase(); if (q.isEmpty) return _items; @@ -803,6 +820,20 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { 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) { @@ -829,16 +860,7 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { 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; - } - } - }); + _applyCategorySelection(selected.id, selected.path); } @override @@ -888,19 +910,17 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { ), 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!, - ), - ), + 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( diff --git a/flutter/lib/features/admin/presentation/admin_pantry_panel.dart b/flutter/lib/features/admin/presentation/admin_pantry_panel.dart index 56fe7d37..6a7db93e 100644 --- a/flutter/lib/features/admin/presentation/admin_pantry_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_pantry_panel.dart @@ -5,6 +5,7 @@ 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 '../data/admin_repository.dart'; import '../domain/admin_category_node.dart'; @@ -29,6 +30,7 @@ class _AdminPantryPanelState extends ConsumerState { List _items = []; List _products = []; List _categories = []; + List _categoryOptions = []; List _users = []; @override @@ -55,6 +57,7 @@ class _AdminPantryPanelState extends ConsumerState { _items = results[0] as List; _products = results[1] as List; _categories = results[2] as List; + _categoryOptions = _flattenCategoryOptions(_categories); _users = results[3] as List; }); } catch (e) { @@ -77,6 +80,20 @@ class _AdminPantryPanelState extends ConsumerState { }).toList(); } + List _flattenCategoryOptions( + List nodes, [ + List parents = const [], + ]) { + final result = []; + 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 _addItem() async { final values = await _showPantryFormDialog(initialOwnerUserId: _selectedUserId); if (values == null) return; @@ -513,6 +530,20 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { 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) { @@ -539,16 +570,7 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { 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; - } - } - }); + _applyCategorySelection(selected.id, selected.path); } @override @@ -585,19 +607,17 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { ), 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!, - ), - ), + 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( diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart index c5cee390..d0d9ed1d 100644 --- a/flutter/lib/features/admin/presentation/admin_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/searchable_category_field.dart'; import '../data/admin_repository.dart'; import '../domain/admin_ai_categorize_result.dart'; import '../domain/admin_category_node.dart'; @@ -102,6 +103,13 @@ class _AdminProductsPanelState extends ConsumerState { return result; } + List _categoryOptionItems() { + return [ + const (value: '__remove__', label: 'Ingen kategori'), + ..._cachedCategoryOptions.map((option) => (value: option.value, label: option.label)), + ]; + } + Future _applyBulkCategory() async { if (_selectedIds.isEmpty || _isApplying) return; if (_bulkCategoryValue == null) { @@ -525,7 +533,6 @@ class _AdminProductsPanelState extends ConsumerState { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final categoryOptions = _cachedCategoryOptions; final filtered = _products.where((product) { if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) { return false; @@ -647,24 +654,10 @@ class _AdminProductsPanelState extends ConsumerState { if (!_showDeletedOnly) SizedBox( width: 260, - child: DropdownButtonFormField( - initialValue: _bulkCategoryValue, - decoration: InputDecoration( - labelText: context.l10n.adminBulkSetCategory, - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: '__remove__', - child: Text(context.l10n.adminRemoveCategory), - ), - ...categoryOptions.map( - (option) => DropdownMenuItem( - value: option.value, - child: Text(option.label), - ), - ), - ], + child: SearchableCategoryField( + options: _categoryOptionItems(), + value: _bulkCategoryValue, + label: context.l10n.adminBulkSetCategory, onChanged: (value) => setState(() => _bulkCategoryValue = value), ), ), @@ -769,28 +762,11 @@ class _AdminProductsPanelState extends ConsumerState { children: [ SizedBox( width: 280, - child: DropdownButtonFormField( - key: ValueKey( - 'row-category-${product.id}-${_rowCategoryFor(product)}', - ), - initialValue: _rowCategoryFor(product), - decoration: InputDecoration( - labelText: context.l10n.adminInlineCategory, - border: const OutlineInputBorder(), - isDense: true, - ), - items: [ - DropdownMenuItem( - value: '__remove__', - child: Text(context.l10n.adminNoCategory), - ), - ...categoryOptions.map( - (option) => DropdownMenuItem( - value: option.value, - child: Text(option.label), - ), - ), - ], + child: SearchableCategoryField( + key: ValueKey('row-category-${product.id}-${_rowCategoryFor(product)}'), + options: _categoryOptionItems(), + value: _rowCategoryFor(product), + label: context.l10n.adminInlineCategory, onChanged: (value) { if (value == null) return; setState(() => _rowCategoryValue[product.id] = value);