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',
|
||||
);
|
||||
|
||||
const _adminHeaderDestination = _AppDestination(
|
||||
path: '/admin',
|
||||
title: 'Admin',
|
||||
icon: Icons.admin_panel_settings_outlined,
|
||||
label: 'Admin',
|
||||
);
|
||||
|
||||
class AppShell extends ConsumerWidget {
|
||||
final String location;
|
||||
final ValueChanged<String> 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<String>(
|
||||
value: 'admin',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.admin_panel_settings_outlined),
|
||||
title: Text('Admin'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user