feat: Introduce SearchableCategoryField component and integrate it into admin panels for enhanced category selection
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user