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);
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 '../../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: [
@@ -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<AdminDatabasePanel> {
_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<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
Widget build(BuildContext context) {
final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab);
@@ -178,10 +141,6 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
],
),
const SizedBox(height: 8),
Text(
currentTab.summary,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
@@ -192,9 +151,9 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
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),
],
),
);
@@ -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<AdminScreen> 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<AdminScreen> {
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,
);
}
}
@@ -345,22 +345,6 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
@override
Widget build(BuildContext 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) {
return const Center(child: CircularProgressIndicator());
}
@@ -440,38 +424,43 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
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<AdminUsersPanel> {
),
],
),
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),
],
),