From afbc5b91b2734b4831632edcb46be0a1f6468562 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Mon, 11 May 2026 10:10:03 +0200 Subject: [PATCH] feat: Enhance admin panel navigation and UI by implementing tab management and improving layout structure --- flutter/lib/core/router/app_router.dart | 7 +- flutter/lib/core/ui/app_shell.dart | 37 +++++- .../presentation/admin_database_panel.dart | 63 ++-------- .../admin/presentation/admin_screen.dart | 61 ++++------ .../admin/presentation/admin_users_panel.dart | 108 +++++++++--------- 5 files changed, 128 insertions(+), 148 deletions(-) diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 687a6717..14d676d8 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -267,7 +267,12 @@ final appRouterProvider = Provider((ref) { .maybeWhen(data: (t) => t, orElse: () => null); 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); + }, ), ], ), diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index d61f05b0..cefde520 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../features/auth/data/auth_providers.dart'; +import '../../features/admin/presentation/admin_screen.dart'; import '../../features/recipes/data/recipes_grid_provider.dart'; const _profileHeaderDestination = _AppDestination( @@ -93,6 +94,7 @@ class AppShell extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final locationUri = Uri.parse(location); final isAdmin = ref.watch(isAdminProvider); final dests = _destinations(isAdmin); final selectedIndex = _selectedIndex(dests); @@ -109,10 +111,43 @@ class AppShell extends ConsumerWidget { final isRecipesRoute = location.startsWith('/recipes') && !location.startsWith('/recipes/'); 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( appBar: AppBar( - title: Text(selectedDestination.title), + title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title), bottom: isImportRoute ? const TabBar( tabs: [ diff --git a/flutter/lib/features/admin/presentation/admin_database_panel.dart b/flutter/lib/features/admin/presentation/admin_database_panel.dart index 4624c9fa..973a9c34 100644 --- a/flutter/lib/features/admin/presentation/admin_database_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_database_panel.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; @@ -18,14 +17,12 @@ enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, alias class _DatabaseTabConfig { final _DatabaseTab tab; final String title; - final String summary; - final Widget Function(BuildContext context) buildPanel; + final Widget panel; const _DatabaseTabConfig({ required this.tab, required this.title, - required this.summary, - required this.buildPanel, + required this.panel, }); } @@ -46,44 +43,37 @@ class _AdminDatabasePanelState extends ConsumerState { _DatabaseTabConfig( tab: _DatabaseTab.inventory, title: context.l10n.profileInventoryTab, - summary: 'Granska, filtrera och redigera inventory-poster. Välj användare för att arbeta på en specifik ägares data.', - buildPanel: (_) => const AdminInventoryPanel(embedded: true), + panel: const AdminInventoryPanel(embedded: true), ), _DatabaseTabConfig( tab: _DatabaseTab.pantry, title: context.l10n.profilePantryTab, - summary: 'Granska och redigera användarnas baslager. Flytta poster till inventarie eller ta bort dem vid behov.', - buildPanel: (_) => const AdminPantryPanel(embedded: true), + panel: const AdminPantryPanel(embedded: true), ), _DatabaseTabConfig( tab: _DatabaseTab.products, title: context.l10n.profileProductsTab, - summary: 'Hantera globala produkter: kategorisering, restaurering, merge och AI-stöd.', - buildPanel: (_) => const AdminProductsPanel(embedded: true), + panel: const AdminProductsPanel(embedded: true), ), _DatabaseTabConfig( tab: _DatabaseTab.privateProducts, title: 'Privata produkter', - summary: 'Promotera privata produkter till den globala produkt-tabellen.', - buildPanel: (_) => const AdminPrivateProductsPanel(embedded: true), + panel: const AdminPrivateProductsPanel(embedded: true), ), _DatabaseTabConfig( tab: _DatabaseTab.pending, title: context.l10n.profilePendingTab, - summary: 'Godkänn eller avslå nya produkter som föreslagits av användare.', - buildPanel: (_) => const AdminPendingProductsPanel(embedded: true), + panel: const AdminPendingProductsPanel(embedded: true), ), _DatabaseTabConfig( tab: _DatabaseTab.aliases, title: 'Alias', - summary: 'Hantera globala alias som används i receipt-importens första matchningssteg.', - buildPanel: (_) => const AdminAliasesPanel(embedded: true), + panel: const AdminAliasesPanel(embedded: true), ), _DatabaseTabConfig( tab: _DatabaseTab.ai, title: 'AI', - summary: 'Se vilka AI-modeller som används och hur de är exponerade i systemet.', - buildPanel: (_) => const AdminAiPanel(embedded: true), + panel: const AdminAiPanel(embedded: true), ), ]; @@ -105,33 +95,6 @@ class _AdminDatabasePanelState extends ConsumerState { } } - 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 Widget build(BuildContext context) { final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab); @@ -178,10 +141,6 @@ class _AdminDatabasePanelState extends ConsumerState { ], ), const SizedBox(height: 8), - Text( - currentTab.summary, - style: Theme.of(context).textTheme.bodySmall, - ), ], ), ), @@ -192,9 +151,9 @@ class _AdminDatabasePanelState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded(flex: 1, child: header), + header, const SizedBox(height: 12), - Expanded(flex: 6, child: _panelShell(title: currentTab.title, description: currentTab.summary, child: currentTab.buildPanel(context))), + Expanded(child: currentTab.panel), ], ), ); diff --git a/flutter/lib/features/admin/presentation/admin_screen.dart b/flutter/lib/features/admin/presentation/admin_screen.dart index a50db86d..b49f1b84 100644 --- a/flutter/lib/features/admin/presentation/admin_screen.dart +++ b/flutter/lib/features/admin/presentation/admin_screen.dart @@ -1,50 +1,35 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/l10n/l10n.dart'; import 'admin_database_panel.dart'; import 'admin_users_panel.dart'; -class AdminScreen extends ConsumerStatefulWidget { - const AdminScreen({super.key}); +enum AdminViewTab { users, database } - @override - ConsumerState createState() => _AdminScreenState(); +extension AdminViewTabX on AdminViewTab { + static AdminViewTab fromQuery(String? value) { + return switch (value) { + 'database' => AdminViewTab.database, + _ => AdminViewTab.users, + }; + } + + String get queryValue => this == AdminViewTab.database ? 'database' : 'users'; } -class _AdminScreenState extends ConsumerState { +class AdminScreen extends StatelessWidget { + final AdminViewTab initialTab; + + const AdminScreen({super.key, this.initialTab = AdminViewTab.users}); + @override Widget build(BuildContext context) { - return DefaultTabController( - length: 2, - child: Column( - children: [ - Material( - color: Theme.of(context).colorScheme.surface, - child: TabBar( - isScrollable: true, - 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), - ), - ], - ), - ), - ], - ), + final activePanel = switch (initialTab) { + AdminViewTab.users => const AdminUsersPanel(embedded: true), + AdminViewTab.database => const AdminDatabasePanel(embedded: true), + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: activePanel, ); } } diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index 79fb9db5..2db54514 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -345,22 +345,6 @@ class _AdminUsersPanelState extends ConsumerState { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final activeFilters = [ - 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) { return const Center(child: CircularProgressIndicator()); } @@ -440,38 +424,43 @@ class _AdminUsersPanelState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilterChip( - label: const Text('Endast admin'), - selected: _filterAdminOnly, - onSelected: (value) => setState(() => _filterAdminOnly = value), - ), - FilterChip( - label: const Text('Endast premium'), - selected: _filterPremiumOnly, - onSelected: (value) => setState(() => _filterPremiumOnly = value), - ), - FilterChip( - label: const Text('Delning avstängd'), - selected: _filterSharingOffOnly, - onSelected: (value) => setState(() => _filterSharingOffOnly = value), - ), - if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly) - TextButton.icon( - onPressed: () { - setState(() { - _filterAdminOnly = false; - _filterPremiumOnly = false; - _filterSharingOffOnly = false; - }); - }, - icon: const Icon(Icons.clear_all), - label: const Text('Rensa filter'), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip( + label: const Text('Endast admin'), + selected: _filterAdminOnly, + onSelected: (value) => setState(() => _filterAdminOnly = value), ), - ], + const SizedBox(width: 8), + FilterChip( + label: const Text('Endast premium'), + selected: _filterPremiumOnly, + onSelected: (value) => setState(() => _filterPremiumOnly = value), + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('Delning avstängd'), + selected: _filterSharingOffOnly, + onSelected: (value) => setState(() => _filterSharingOffOnly = value), + ), + if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly) ...[ + const SizedBox(width: 8), + TextButton.icon( + onPressed: () { + setState(() { + _filterAdminOnly = false; + _filterPremiumOnly = false; + _filterSharingOffOnly = false; + }); + }, + icon: const Icon(Icons.clear_all), + label: const Text('Rensa filter'), + ), + ], + ], + ), ), const SizedBox(height: 8), Row( @@ -520,17 +509,24 @@ class _AdminUsersPanelState extends ConsumerState { ), ], ), - Text( - 'Visar ${visibleUsers.length} av ${_users.length} användare', - style: theme.textTheme.bodySmall, - ), const SizedBox(height: 8), - FilledButton.icon( - onPressed: _createUser, - icon: const Icon(Icons.person_add_outlined), - label: Text(context.l10n.adminNewUser), + Row( + children: [ + Expanded( + child: Text( + 'Visar ${visibleUsers.length} av ${_users.length} användare', + style: theme.textTheme.bodySmall, + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _createUser, + icon: const Icon(Icons.person_add_outlined), + label: Text(context.l10n.adminNewUser), + ), + ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Expanded(child: list), ], ),