feat: Enhance admin panel navigation and UI by implementing tab management and improving layout structure
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 10:10:03 +02:00
parent 06492ff099
commit afbc5b91b2
5 changed files with 128 additions and 148 deletions
+6 -1
View File
@@ -267,7 +267,12 @@ final appRouterProvider = Provider<GoRouter>((ref) {
.maybeWhen(data: (t) => t, orElse: () => null); .maybeWhen(data: (t) => t, orElse: () => null);
return jwtIsAdmin(token) ? null : '/recipes'; return jwtIsAdmin(token) ? null : '/recipes';
}, },
builder: (context, state) => const AdminScreen(), builder: (context, state) {
final tab = AdminViewTabX.fromQuery(
state.uri.queryParameters['tab'],
);
return AdminScreen(initialTab: tab);
},
), ),
], ],
), ),
+36 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../features/auth/data/auth_providers.dart'; import '../../features/auth/data/auth_providers.dart';
import '../../features/admin/presentation/admin_screen.dart';
import '../../features/recipes/data/recipes_grid_provider.dart'; import '../../features/recipes/data/recipes_grid_provider.dart';
const _profileHeaderDestination = _AppDestination( const _profileHeaderDestination = _AppDestination(
@@ -93,6 +94,7 @@ class AppShell extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final locationUri = Uri.parse(location);
final isAdmin = ref.watch(isAdminProvider); final isAdmin = ref.watch(isAdminProvider);
final dests = _destinations(isAdmin); final dests = _destinations(isAdmin);
final selectedIndex = _selectedIndex(dests); final selectedIndex = _selectedIndex(dests);
@@ -109,10 +111,43 @@ class AppShell extends ConsumerWidget {
final isRecipesRoute = location.startsWith('/recipes') && final isRecipesRoute = location.startsWith('/recipes') &&
!location.startsWith('/recipes/'); !location.startsWith('/recipes/');
final isImportRoute = location == '/import'; final isImportRoute = location == '/import';
final isAdminRoute = location.startsWith('/admin');
final adminTab = AdminViewTabX.fromQuery(
locationUri.queryParameters['tab'],
);
void navigateToAdminTab(AdminViewTab tab) {
final target = '/admin?tab=${tab.queryValue}';
if (target != location && context.mounted) {
onNavigateToPath(target);
}
}
Widget buildAdminTitle() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ChoiceChip(
label: const Text('Användare'),
selected: adminTab == AdminViewTab.users,
onSelected: (_) => navigateToAdminTab(AdminViewTab.users),
),
const SizedBox(width: 8),
ChoiceChip(
label: const Text('Databas'),
selected: adminTab == AdminViewTab.database,
onSelected: (_) => navigateToAdminTab(AdminViewTab.database),
),
],
),
);
}
Widget shell = Scaffold( Widget shell = Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(selectedDestination.title), title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
bottom: isImportRoute bottom: isImportRoute
? const TabBar( ? const TabBar(
tabs: [ tabs: [
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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';
@@ -18,14 +17,12 @@ enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, alias
class _DatabaseTabConfig { class _DatabaseTabConfig {
final _DatabaseTab tab; final _DatabaseTab tab;
final String title; final String title;
final String summary; final Widget panel;
final Widget Function(BuildContext context) buildPanel;
const _DatabaseTabConfig({ const _DatabaseTabConfig({
required this.tab, required this.tab,
required this.title, required this.title,
required this.summary, required this.panel,
required this.buildPanel,
}); });
} }
@@ -46,44 +43,37 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
_DatabaseTabConfig( _DatabaseTabConfig(
tab: _DatabaseTab.inventory, tab: _DatabaseTab.inventory,
title: context.l10n.profileInventoryTab, title: context.l10n.profileInventoryTab,
summary: 'Granska, filtrera och redigera inventory-poster. Välj användare för att arbeta på en specifik ägares data.', panel: const AdminInventoryPanel(embedded: true),
buildPanel: (_) => const AdminInventoryPanel(embedded: true),
), ),
_DatabaseTabConfig( _DatabaseTabConfig(
tab: _DatabaseTab.pantry, tab: _DatabaseTab.pantry,
title: context.l10n.profilePantryTab, title: context.l10n.profilePantryTab,
summary: 'Granska och redigera användarnas baslager. Flytta poster till inventarie eller ta bort dem vid behov.', panel: const AdminPantryPanel(embedded: true),
buildPanel: (_) => const AdminPantryPanel(embedded: true),
), ),
_DatabaseTabConfig( _DatabaseTabConfig(
tab: _DatabaseTab.products, tab: _DatabaseTab.products,
title: context.l10n.profileProductsTab, title: context.l10n.profileProductsTab,
summary: 'Hantera globala produkter: kategorisering, restaurering, merge och AI-stöd.', panel: const AdminProductsPanel(embedded: true),
buildPanel: (_) => const AdminProductsPanel(embedded: true),
), ),
_DatabaseTabConfig( _DatabaseTabConfig(
tab: _DatabaseTab.privateProducts, tab: _DatabaseTab.privateProducts,
title: 'Privata produkter', title: 'Privata produkter',
summary: 'Promotera privata produkter till den globala produkt-tabellen.', panel: const AdminPrivateProductsPanel(embedded: true),
buildPanel: (_) => const AdminPrivateProductsPanel(embedded: true),
), ),
_DatabaseTabConfig( _DatabaseTabConfig(
tab: _DatabaseTab.pending, tab: _DatabaseTab.pending,
title: context.l10n.profilePendingTab, title: context.l10n.profilePendingTab,
summary: 'Godkänn eller avslå nya produkter som föreslagits av användare.', panel: const AdminPendingProductsPanel(embedded: true),
buildPanel: (_) => const AdminPendingProductsPanel(embedded: true),
), ),
_DatabaseTabConfig( _DatabaseTabConfig(
tab: _DatabaseTab.aliases, tab: _DatabaseTab.aliases,
title: 'Alias', title: 'Alias',
summary: 'Hantera globala alias som används i receipt-importens första matchningssteg.', panel: const AdminAliasesPanel(embedded: true),
buildPanel: (_) => const AdminAliasesPanel(embedded: true),
), ),
_DatabaseTabConfig( _DatabaseTabConfig(
tab: _DatabaseTab.ai, tab: _DatabaseTab.ai,
title: 'AI', title: 'AI',
summary: 'Se vilka AI-modeller som används och hur de är exponerade i systemet.', panel: const AdminAiPanel(embedded: true),
buildPanel: (_) => const AdminAiPanel(embedded: true),
), ),
]; ];
@@ -105,33 +95,6 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
} }
} }
Widget _panelShell({
required String title,
required String description,
required Widget child,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(description),
],
),
),
),
const SizedBox(height: 12),
Expanded(child: child),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab); final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab);
@@ -178,10 +141,6 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(
currentTab.summary,
style: Theme.of(context).textTheme.bodySmall,
),
], ],
), ),
), ),
@@ -192,9 +151,9 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded(flex: 1, child: header), header,
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded(flex: 6, child: _panelShell(title: currentTab.title, description: currentTab.summary, child: currentTab.buildPanel(context))), Expanded(child: currentTab.panel),
], ],
), ),
); );
@@ -1,50 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/l10n/l10n.dart';
import 'admin_database_panel.dart'; import 'admin_database_panel.dart';
import 'admin_users_panel.dart'; import 'admin_users_panel.dart';
class AdminScreen extends ConsumerStatefulWidget { enum AdminViewTab { users, database }
const AdminScreen({super.key});
@override extension AdminViewTabX on AdminViewTab {
ConsumerState<AdminScreen> createState() => _AdminScreenState(); static AdminViewTab fromQuery(String? value) {
return switch (value) {
'database' => AdminViewTab.database,
_ => AdminViewTab.users,
};
} }
class _AdminScreenState extends ConsumerState<AdminScreen> { String get queryValue => this == AdminViewTab.database ? 'database' : 'users';
}
class AdminScreen extends StatelessWidget {
final AdminViewTab initialTab;
const AdminScreen({super.key, this.initialTab = AdminViewTab.users});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTabController( final activePanel = switch (initialTab) {
length: 2, AdminViewTab.users => const AdminUsersPanel(embedded: true),
child: Column( AdminViewTab.database => const AdminDatabasePanel(embedded: true),
children: [ };
Material(
color: Theme.of(context).colorScheme.surface, return Padding(
child: TabBar( padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
isScrollable: true, child: activePanel,
padding: const EdgeInsets.symmetric(horizontal: 12),
tabs: [
Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)),
const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)),
],
),
),
const Expanded(
child: TabBarView(
children: [
Padding(
padding: EdgeInsets.fromLTRB(12, 8, 12, 8),
child: AdminUsersPanel(embedded: true),
),
Padding(
padding: EdgeInsets.fromLTRB(12, 8, 12, 8),
child: AdminDatabasePanel(embedded: true),
),
],
),
),
],
),
); );
} }
} }
@@ -345,22 +345,6 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final activeFilters = <String>[
if (_filterAdminOnly) 'Endast admin',
if (_filterPremiumOnly) 'Endast premium',
if (_filterSharingOffOnly) 'Delning avstängd',
];
final searchSummary = _search.trim().isEmpty
? 'Ingen aktiv sökning'
: 'Sökning: "${_search.trim()}"';
final filterSummary = activeFilters.isEmpty
? 'Inga aktiva snabbfilter'
: 'Aktiva filter: ${activeFilters.join(', ')}';
final viewSummary = 'Sortering: ${_sortLabel(_sort)}$searchSummary$filterSummary';
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -440,26 +424,29 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( SingleChildScrollView(
spacing: 8, scrollDirection: Axis.horizontal,
runSpacing: 8, child: Row(
children: [ children: [
FilterChip( FilterChip(
label: const Text('Endast admin'), label: const Text('Endast admin'),
selected: _filterAdminOnly, selected: _filterAdminOnly,
onSelected: (value) => setState(() => _filterAdminOnly = value), onSelected: (value) => setState(() => _filterAdminOnly = value),
), ),
const SizedBox(width: 8),
FilterChip( FilterChip(
label: const Text('Endast premium'), label: const Text('Endast premium'),
selected: _filterPremiumOnly, selected: _filterPremiumOnly,
onSelected: (value) => setState(() => _filterPremiumOnly = value), onSelected: (value) => setState(() => _filterPremiumOnly = value),
), ),
const SizedBox(width: 8),
FilterChip( FilterChip(
label: const Text('Delning avstängd'), label: const Text('Delning avstängd'),
selected: _filterSharingOffOnly, selected: _filterSharingOffOnly,
onSelected: (value) => setState(() => _filterSharingOffOnly = value), onSelected: (value) => setState(() => _filterSharingOffOnly = value),
), ),
if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly) if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly) ...[
const SizedBox(width: 8),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: () {
setState(() { setState(() {
@@ -472,6 +459,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
label: const Text('Rensa filter'), label: const Text('Rensa filter'),
), ),
], ],
],
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@@ -520,17 +509,24 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
), ),
], ],
), ),
Text( const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
'Visar ${visibleUsers.length} av ${_users.length} användare', 'Visar ${visibleUsers.length} av ${_users.length} användare',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
const SizedBox(height: 8), ),
const SizedBox(width: 8),
FilledButton.icon( FilledButton.icon(
onPressed: _createUser, onPressed: _createUser,
icon: const Icon(Icons.person_add_outlined), icon: const Icon(Icons.person_add_outlined),
label: Text(context.l10n.adminNewUser), label: Text(context.l10n.adminNewUser),
), ),
const SizedBox(height: 16), ],
),
const SizedBox(height: 12),
Expanded(child: list), Expanded(child: list),
], ],
), ),