From 8ea2b97c27dd912d9d5bfb8f7b62164255773506 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 25 Apr 2026 08:22:14 +0200 Subject: [PATCH] feat: enhance profile screen with tab navigation and admin panels - Added tab navigation for profile, database, users, suggestions, and AI sections. - Implemented database management with inventory, pantry, and products tabs. - Created Admin AI panel to display AI model information. - Introduced Admin Pending Products panel for managing product approvals. - Developed Admin Users panel for user management, including role changes and password resets. - Added data models for AI models and pending products. --- flutter/lib/core/api/api_paths.dart | 6 + flutter/lib/core/ui/app_shell.dart | 49 +- .../features/admin/data/admin_repository.dart | 35 ++ .../features/admin/domain/ai_model_info.dart | 29 + .../admin/domain/pending_product.dart | 54 ++ .../admin/presentation/admin_ai_panel.dart | 110 ++++ .../admin_pending_products_panel.dart | 154 ++++++ .../admin/presentation/admin_screen.dart | 394 +------------- .../admin/presentation/admin_users_panel.dart | 496 ++++++++++++++++++ .../profile/presentation/profile_screen.dart | 450 +++++++++++++--- 10 files changed, 1289 insertions(+), 488 deletions(-) create mode 100644 flutter/lib/features/admin/domain/ai_model_info.dart create mode 100644 flutter/lib/features/admin/domain/pending_product.dart create mode 100644 flutter/lib/features/admin/presentation/admin_ai_panel.dart create mode 100644 flutter/lib/features/admin/presentation/admin_pending_products_panel.dart create mode 100644 flutter/lib/features/admin/presentation/admin_users_panel.dart diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index a6f19d9f..8f9e872b 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -4,6 +4,12 @@ class AuthApiPaths { class ProductApiPaths { static const list = '/products'; + static const pending = '/products/pending'; + static String setStatus(int id) => '/products/$id/status'; +} + +class AiApiPaths { + static const models = '/ai/models'; } class RecipeApiPaths { diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 7c092f4f..6a27c95b 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -81,13 +81,6 @@ class AppShell extends ConsumerWidget { final selectedDestination = dests[selectedIndex]; final isWide = MediaQuery.of(context).size.width >= 900; - Future logout() async { - await ref.read(authStateProvider.notifier).logout(); - if (context.mounted) { - context.go('/login'); - } - } - void navigateTo(int index) { final target = dests[index].path; if (target != location && context.mounted) { @@ -98,6 +91,13 @@ class AppShell extends ConsumerWidget { final isRecipesRoute = location.startsWith('/recipes') && !location.startsWith('/recipes/'); + Future logout() async { + await ref.read(authStateProvider.notifier).logout(); + if (context.mounted) { + context.go('/login'); + } + } + return Scaffold( appBar: AppBar( title: Text(selectedDestination.title), @@ -139,10 +139,37 @@ class AppShell extends ConsumerWidget { ); }, ), - IconButton( - tooltip: 'Logga ut', - icon: const Icon(Icons.logout), - onPressed: logout, + PopupMenuButton( + tooltip: 'Profil och konto', + icon: const Icon(Icons.account_circle_outlined), + onSelected: (value) { + switch (value) { + case 'profile': + if (location != '/profile' && context.mounted) { + context.go('/profile'); + } + case 'logout': + logout(); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'profile', + child: ListTile( + leading: Icon(Icons.person_outline), + title: Text('Profil'), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'logout', + child: ListTile( + leading: Icon(Icons.logout), + title: Text('Logga ut'), + contentPadding: EdgeInsets.zero, + ), + ), + ], ), ], ), diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 52c4c178..fdb3c041 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -3,6 +3,8 @@ import '../../../core/api/api_client.dart'; import '../../../core/api/api_paths.dart'; import '../../../core/api/guarded_api_call.dart'; import '../../auth/data/auth_providers.dart'; +import '../domain/ai_model_info.dart'; +import '../domain/pending_product.dart'; import '../domain/user_admin.dart'; final adminRepositoryProvider = Provider((ref) { @@ -80,4 +82,37 @@ class AdminRepository { ); return (result as Map); } + + Future> listPendingProducts() async { + final token = await _token(); + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson(ProductApiPaths.pending, token: token), + ); + return (data as List) + .map((e) => PendingProduct.fromJson(e as Map)) + .toList(); + } + + Future setProductStatus(int productId, String status) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.patchJson( + ProductApiPaths.setStatus(productId), + body: {'status': status}, + token: token, + ), + ); + } + + Future> listAiModels() async { + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson(AiApiPaths.models), + ); + return (data as List) + .map((e) => AiModelInfo.fromJson(e as Map)) + .toList(); + } } diff --git a/flutter/lib/features/admin/domain/ai_model_info.dart b/flutter/lib/features/admin/domain/ai_model_info.dart new file mode 100644 index 00000000..c78fad9d --- /dev/null +++ b/flutter/lib/features/admin/domain/ai_model_info.dart @@ -0,0 +1,29 @@ +class AiModelInfo { + final String id; + final String name; + final String description; + final String model; + final String path; + final String trigger; + final String access; + + const AiModelInfo({ + required this.id, + required this.name, + required this.description, + required this.model, + required this.path, + required this.trigger, + required this.access, + }); + + factory AiModelInfo.fromJson(Map json) => AiModelInfo( + id: (json['id'] ?? '').toString(), + name: (json['name'] ?? '').toString(), + description: (json['description'] ?? '').toString(), + model: (json['model'] ?? '').toString(), + path: (json['path'] ?? '').toString(), + trigger: (json['trigger'] ?? '').toString(), + access: (json['access'] ?? '').toString(), + ); +} \ No newline at end of file diff --git a/flutter/lib/features/admin/domain/pending_product.dart b/flutter/lib/features/admin/domain/pending_product.dart new file mode 100644 index 00000000..56c24bf6 --- /dev/null +++ b/flutter/lib/features/admin/domain/pending_product.dart @@ -0,0 +1,54 @@ +class PendingProduct { + final int id; + final String name; + final String? canonicalName; + final DateTime? createdAt; + final String? categoryPath; + final String? ownerUsername; + + const PendingProduct({ + required this.id, + required this.name, + this.canonicalName, + this.createdAt, + this.categoryPath, + this.ownerUsername, + }); + + String get displayName => + canonicalName != null && canonicalName!.trim().isNotEmpty + ? canonicalName! + : name; + + factory PendingProduct.fromJson(Map json) { + final categoryRef = json['categoryRef']; + final owner = json['owner']; + final parts = []; + if (categoryRef is Map) { + final parent = categoryRef['parent']; + if (parent is Map) { + final parentName = parent['name']?.toString(); + if (parentName != null && parentName.trim().isNotEmpty) { + parts.add(parentName.trim()); + } + } + final name = categoryRef['name']?.toString(); + if (name != null && name.trim().isNotEmpty) { + parts.add(name.trim()); + } + } + + return PendingProduct( + id: (json['id'] as num).toInt(), + name: (json['name'] ?? '').toString(), + canonicalName: json['canonicalName']?.toString(), + createdAt: json['createdAt'] == null + ? null + : DateTime.tryParse(json['createdAt'].toString()), + categoryPath: parts.isEmpty ? null : parts.join(' > '), + ownerUsername: owner is Map + ? owner['username']?.toString() + : null, + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_ai_panel.dart b/flutter/lib/features/admin/presentation/admin_ai_panel.dart new file mode 100644 index 00000000..aba8050c --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_ai_panel.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../data/admin_repository.dart'; +import '../domain/ai_model_info.dart'; + +class AdminAiPanel extends ConsumerStatefulWidget { + final bool embedded; + + const AdminAiPanel({super.key, this.embedded = false}); + + @override + ConsumerState createState() => _AdminAiPanelState(); +} + +class _AdminAiPanelState extends ConsumerState { + bool _isLoading = true; + String? _error; + List _models = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final models = await ref.read(adminRepositoryProvider).listAiModels(); + if (!mounted) return; + setState(() => _models = models); + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Color _chipColor(String value, ColorScheme scheme) { + final lower = value.toLowerCase(); + if (lower.contains('admin')) return scheme.primaryContainer; + if (lower.contains('premium')) return scheme.tertiaryContainer; + return scheme.secondaryContainer; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (_isLoading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: const Text('Försök igen')), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Översikt över AI-funktioner som backend exponerar.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + ..._models.map( + (model) => Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(model.name, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text(model.description), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text(model.model)), + Chip( + label: Text(model.access), + backgroundColor: _chipColor(model.access, theme.colorScheme), + ), + Chip(label: Text(model.trigger)), + ], + ), + const SizedBox(height: 8), + Text('Sida: ${model.path}', style: theme.textTheme.bodySmall), + ], + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart b/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart new file mode 100644 index 00000000..d046c4e7 --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../data/admin_repository.dart'; +import '../domain/pending_product.dart'; + +class AdminPendingProductsPanel extends ConsumerStatefulWidget { + final bool embedded; + + const AdminPendingProductsPanel({super.key, this.embedded = false}); + + @override + ConsumerState createState() => + _AdminPendingProductsPanelState(); +} + +class _AdminPendingProductsPanelState + extends ConsumerState { + bool _isLoading = true; + String? _error; + int? _processingId; + List _products = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final products = await ref.read(adminRepositoryProvider).listPendingProducts(); + if (!mounted) return; + setState(() => _products = products); + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _handleAction(PendingProduct product, String status) async { + setState(() => _processingId = product.id); + try { + await ref.read(adminRepositoryProvider).setProductStatus(product.id, status); + if (!mounted) return; + setState(() { + _products = _products.where((p) => p.id != product.id).toList(); + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } finally { + if (mounted) setState(() => _processingId = null); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (_isLoading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: const Text('Försök igen')), + ], + ), + ); + } + if (_products.isEmpty) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Inga väntande produktförslag.', + style: theme.textTheme.bodyMedium, + ), + ), + ); + } + + final content = ListView.builder( + shrinkWrap: widget.embedded, + physics: widget.embedded ? const NeverScrollableScrollPhysics() : null, + itemCount: _products.length, + itemBuilder: (context, index) { + final product = _products[index]; + final isProcessing = _processingId == product.id; + return Card( + child: ListTile( + title: Text(product.displayName), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (product.displayName != product.name) + Text(product.name, style: theme.textTheme.bodySmall), + Text('Kategori: ${product.categoryPath ?? '—'}'), + Text('Föreslagen av: ${product.ownerUsername ?? '—'}'), + Text( + 'Datum: ${product.createdAt == null ? '—' : MaterialLocalizations.of(context).formatShortDate(product.createdAt!)}', + style: theme.textTheme.bodySmall, + ), + ], + ), + isThreeLine: true, + trailing: Wrap( + spacing: 8, + children: [ + FilledButton( + onPressed: isProcessing + ? null + : () => _handleAction(product, 'active'), + child: const Text('Godkänn'), + ), + OutlinedButton( + onPressed: isProcessing + ? null + : () => _handleAction(product, 'rejected'), + child: const Text('Avvisa'), + ), + ], + ), + ), + ); + }, + ); + + if (!widget.embedded) return content; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Godkänn eller avvisa väntande produktförslag direkt från profilsidan.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + content, + ], + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_screen.dart b/flutter/lib/features/admin/presentation/admin_screen.dart index c47ef6b2..481e6051 100644 --- a/flutter/lib/features/admin/presentation/admin_screen.dart +++ b/flutter/lib/features/admin/presentation/admin_screen.dart @@ -1,9 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/api/api_error_mapper.dart'; -import '../data/admin_repository.dart'; -import '../domain/user_admin.dart'; +import 'admin_users_panel.dart'; class AdminScreen extends ConsumerStatefulWidget { const AdminScreen({super.key}); @@ -13,400 +10,13 @@ class AdminScreen extends ConsumerStatefulWidget { } class _AdminScreenState extends ConsumerState { - bool _isLoading = true; - String? _error; - List _users = []; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() { - _isLoading = true; - _error = null; - }); - try { - final users = await ref.read(adminRepositoryProvider).listUsers(); - if (!mounted) return; - setState(() => _users = users); - } catch (e) { - if (!mounted) return; - setState(() => _error = mapErrorToUserMessage(e, context)); - } finally { - if (mounted) setState(() => _isLoading = false); - } - } - - Future _changeRole(UserAdmin user) async { - final newRole = user.isAdmin ? 'user' : 'admin'; - final confirmed = await _confirm( - context, - 'Ändra roll', - 'Ändra ${user.username} till $newRole?', - ); - if (!confirmed || !mounted) return; - try { - await ref.read(adminRepositoryProvider).setRole(user.id, newRole); - if (!mounted) return; - _load(); - } catch (e) { - if (!mounted) return; - _showError(e); - } - } - - Future _resetPassword(UserAdmin user) async { - final confirmed = await _confirm( - context, - 'Återställ lösenord', - 'Generera ett tillfälligt lösenord för ${user.username}?', - ); - if (!confirmed || !mounted) return; - try { - final result = await ref.read(adminRepositoryProvider).resetPassword(user.id); - if (!mounted) return; - final tempPw = result['temporaryPassword'] as String? ?? ''; - await showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Tillfälligt lösenord'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Lösenord för ${user.username}:'), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: SelectableText( - tempPw, - style: const TextStyle(fontFamily: 'monospace', fontWeight: FontWeight.bold), - ), - ), - IconButton( - icon: const Icon(Icons.copy), - tooltip: 'Kopiera', - onPressed: () => Clipboard.setData(ClipboardData(text: tempPw)), - ), - ], - ), - const SizedBox(height: 8), - const Text('Användaren måste byta lösenord vid nästa inloggning.', style: TextStyle(fontSize: 12)), - ], - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Stäng'))], - ), - ); - } catch (e) { - if (!mounted) return; - _showError(e); - } - } - - Future _deleteUser(UserAdmin user) async { - final confirmed = await _confirm( - context, - 'Ta bort användare', - 'Ta bort ${user.username} permanent? Detta går inte att ångra.', - destructive: true, - ); - if (!confirmed || !mounted) return; - try { - await ref.read(adminRepositoryProvider).deleteUser(user.id); - if (!mounted) return; - _load(); - } catch (e) { - if (!mounted) return; - _showError(e); - } - } - - Future _createUser() async { - final result = await showDialog>( - context: context, - builder: (_) => const _CreateUserDialog(), - ); - if (result == null || !mounted) return; - try { - await ref.read(adminRepositoryProvider).createUser( - username: result['username']!, - email: result['email']!, - password: result['password']!, - role: result['role'] ?? 'user', - ); - if (!mounted) return; - _load(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Användare ${result['username']} skapad.')), - ); - } catch (e) { - if (!mounted) return; - _showError(e); - } - } - - void _showError(Object e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(mapErrorToUserMessage(e, context)), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } - - Future _confirm(BuildContext ctx, String title, String body, {bool destructive = false}) async { - final result = await showDialog( - context: ctx, - builder: (_) => AlertDialog( - title: Text(title), - content: Text(body), - actions: [ - TextButton(onPressed: () => Navigator.pop(_, false), child: const Text('Avbryt')), - TextButton( - onPressed: () => Navigator.pop(_, true), - style: destructive - ? TextButton.styleFrom(foregroundColor: Theme.of(ctx).colorScheme.error) - : null, - child: const Text('Bekräfta'), - ), - ], - ), - ); - return result ?? false; - } - @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: const Text('Admin – Användare'), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - tooltip: 'Uppdatera', - onPressed: _isLoading ? null : _load, - ), - ], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: _createUser, - icon: const Icon(Icons.person_add_outlined), - label: const Text('Ny användare'), - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_error!, style: TextStyle(color: theme.colorScheme.error)), - const SizedBox(height: 16), - FilledButton(onPressed: _load, child: const Text('Försök igen')), - ], - ), - ) - : _users.isEmpty - ? const Center(child: Text('Inga användare hittades.')) - : ListView.builder( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 80), - itemCount: _users.length, - itemBuilder: (ctx, i) => _UserTile( - user: _users[i], - onChangeRole: () => _changeRole(_users[i]), - onResetPassword: () => _resetPassword(_users[i]), - onDelete: () => _deleteUser(_users[i]), - ), - ), - ); - } -} - -class _UserTile extends StatelessWidget { - final UserAdmin user; - final VoidCallback onChangeRole; - final VoidCallback onResetPassword; - final VoidCallback onDelete; - - const _UserTile({ - required this.user, - required this.onChangeRole, - required this.onResetPassword, - required this.onDelete, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: user.isAdmin - ? theme.colorScheme.primaryContainer - : theme.colorScheme.secondaryContainer, - child: Icon( - user.isAdmin ? Icons.shield_outlined : Icons.person_outline, - color: user.isAdmin - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSecondaryContainer, - size: 20, - ), - ), - title: Text(user.displayName), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(user.email, style: theme.textTheme.bodySmall), - Row( - children: [ - Chip( - label: Text(user.role), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - labelStyle: theme.textTheme.labelSmall, - ), - if (user.isPremium) ...[ - const SizedBox(width: 4), - Chip( - label: const Text('Premium'), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - labelStyle: theme.textTheme.labelSmall, - backgroundColor: theme.colorScheme.tertiaryContainer, - ), - ], - ], - ), - ], - ), - isThreeLine: true, - trailing: PopupMenuButton( - onSelected: (action) { - switch (action) { - case 'role': - onChangeRole(); - case 'reset': - onResetPassword(); - case 'delete': - onDelete(); - } - }, - itemBuilder: (_) => [ - PopupMenuItem( - value: 'role', - child: Text(user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin'), - ), - const PopupMenuItem(value: 'reset', child: Text('Återställ lösenord')), - const PopupMenuDivider(), - PopupMenuItem( - value: 'delete', - child: Text('Ta bort', style: TextStyle(color: Theme.of(context).colorScheme.error)), - ), - ], - ), - ), - ); - } -} - -class _CreateUserDialog extends StatefulWidget { - const _CreateUserDialog(); - - @override - State<_CreateUserDialog> createState() => _CreateUserDialogState(); -} - -class _CreateUserDialogState extends State<_CreateUserDialog> { - final _formKey = GlobalKey(); - final _usernameCtrl = TextEditingController(); - final _emailCtrl = TextEditingController(); - final _passwordCtrl = TextEditingController(); - String _role = 'user'; - bool _obscure = true; - - @override - void dispose() { - _usernameCtrl.dispose(); - _emailCtrl.dispose(); - _passwordCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Skapa användare'), - content: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: _usernameCtrl, - decoration: const InputDecoration(labelText: 'Användarnamn'), - validator: (v) => (v == null || v.length < 2) ? 'Minst 2 tecken' : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _emailCtrl, - decoration: const InputDecoration(labelText: 'E-post'), - keyboardType: TextInputType.emailAddress, - validator: (v) { - if (v == null || v.isEmpty) return 'Obligatoriskt'; - if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) return 'Ogiltig e-post'; - return null; - }, - ), - const SizedBox(height: 12), - TextFormField( - controller: _passwordCtrl, - decoration: InputDecoration( - labelText: 'Lösenord', - suffixIcon: IconButton( - icon: Icon(_obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined), - onPressed: () => setState(() => _obscure = !_obscure), - ), - ), - obscureText: _obscure, - validator: (v) => (v == null || v.length < 8) ? 'Minst 8 tecken' : null, - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _role, - decoration: const InputDecoration(labelText: 'Roll'), - items: const [ - DropdownMenuItem(value: 'user', child: Text('Användare')), - DropdownMenuItem(value: 'admin', child: Text('Admin')), - ], - onChanged: (v) => setState(() => _role = v ?? 'user'), - ), - ], - ), - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Avbryt')), - FilledButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - Navigator.pop(context, { - 'username': _usernameCtrl.text.trim(), - 'email': _emailCtrl.text.trim(), - 'password': _passwordCtrl.text, - 'role': _role, - }); - } - }, - child: const Text('Skapa'), - ), - ], + body: const AdminUsersPanel(), ); } } diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart new file mode 100644 index 00000000..9e20dec6 --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -0,0 +1,496 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../data/admin_repository.dart'; +import '../domain/user_admin.dart'; + +class AdminUsersPanel extends ConsumerStatefulWidget { + final bool embedded; + + const AdminUsersPanel({super.key, this.embedded = false}); + + @override + ConsumerState createState() => _AdminUsersPanelState(); +} + +class _AdminUsersPanelState extends ConsumerState { + bool _isLoading = true; + String? _error; + List _users = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final users = await ref.read(adminRepositoryProvider).listUsers(); + if (!mounted) return; + setState(() => _users = users); + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _changeRole(UserAdmin user) async { + final newRole = user.isAdmin ? 'user' : 'admin'; + final confirmed = await _confirm( + context, + 'Ändra roll', + 'Ändra ${user.username} till $newRole?', + ); + if (!confirmed || !mounted) return; + try { + await ref.read(adminRepositoryProvider).setRole(user.id, newRole); + if (!mounted) return; + _load(); + } catch (e) { + if (!mounted) return; + _showError(e); + } + } + + Future _resetPassword(UserAdmin user) async { + final confirmed = await _confirm( + context, + 'Återställ lösenord', + 'Generera ett tillfälligt lösenord för ${user.username}?', + ); + if (!confirmed || !mounted) return; + try { + final result = await ref.read(adminRepositoryProvider).resetPassword(user.id); + if (!mounted) return; + final tempPw = result['temporaryPassword'] as String? ?? ''; + await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Tillfälligt lösenord'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Lösenord för ${user.username}:'), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: SelectableText( + tempPw, + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Kopiera', + onPressed: () => Clipboard.setData( + ClipboardData(text: tempPw), + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Användaren måste byta lösenord vid nästa inloggning.', + style: TextStyle(fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Stäng'), + ), + ], + ), + ); + } catch (e) { + if (!mounted) return; + _showError(e); + } + } + + Future _deleteUser(UserAdmin user) async { + final confirmed = await _confirm( + context, + 'Ta bort användare', + 'Ta bort ${user.username} permanent? Detta går inte att ångra.', + destructive: true, + ); + if (!confirmed || !mounted) return; + try { + await ref.read(adminRepositoryProvider).deleteUser(user.id); + if (!mounted) return; + _load(); + } catch (e) { + if (!mounted) return; + _showError(e); + } + } + + Future _createUser() async { + final result = await showDialog>( + context: context, + builder: (_) => const _CreateUserDialog(), + ); + if (result == null || !mounted) return; + try { + await ref.read(adminRepositoryProvider).createUser( + username: result['username']!, + email: result['email']!, + password: result['password']!, + role: result['role'] ?? 'user', + ); + if (!mounted) return; + _load(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Användare ${result['username']} skapad.')), + ); + } catch (e) { + if (!mounted) return; + _showError(e); + } + } + + void _showError(Object e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(mapErrorToUserMessage(e, context)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + Future _confirm( + BuildContext ctx, + String title, + String body, { + bool destructive = false, + }) async { + final result = await showDialog( + context: ctx, + builder: (_) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton( + onPressed: () => Navigator.pop(_, false), + child: const Text('Avbryt'), + ), + TextButton( + onPressed: () => Navigator.pop(_, true), + style: destructive + ? TextButton.styleFrom( + foregroundColor: Theme.of(ctx).colorScheme.error, + ) + : null, + child: const Text('Bekräfta'), + ), + ], + ), + ); + return result ?? false; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: const Text('Försök igen')), + ], + ), + ); + } + if (_users.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.embedded) ...[ + FilledButton.icon( + onPressed: _createUser, + icon: const Icon(Icons.person_add_outlined), + label: const Text('Ny användare'), + ), + const SizedBox(height: 16), + ], + const Text('Inga användare hittades.'), + ], + ); + } + + final list = ListView.builder( + shrinkWrap: widget.embedded, + physics: widget.embedded + ? const NeverScrollableScrollPhysics() + : null, + padding: widget.embedded + ? EdgeInsets.zero + : const EdgeInsets.fromLTRB(16, 8, 16, 80), + itemCount: _users.length, + itemBuilder: (ctx, i) => _UserTile( + user: _users[i], + onChangeRole: () => _changeRole(_users[i]), + onResetPassword: () => _resetPassword(_users[i]), + onDelete: () => _deleteUser(_users[i]), + ), + ); + + if (!widget.embedded) { + return list; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Hantera användare direkt från profilsidan.', + style: theme.textTheme.bodyMedium, + ), + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Uppdatera', + onPressed: _load, + ), + ], + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: _createUser, + icon: const Icon(Icons.person_add_outlined), + label: const Text('Ny användare'), + ), + const SizedBox(height: 16), + list, + ], + ); + } +} + +class _UserTile extends StatelessWidget { + final UserAdmin user; + final VoidCallback onChangeRole; + final VoidCallback onResetPassword; + final VoidCallback onDelete; + + const _UserTile({ + required this.user, + required this.onChangeRole, + required this.onResetPassword, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: user.isAdmin + ? theme.colorScheme.primaryContainer + : theme.colorScheme.secondaryContainer, + child: Icon( + user.isAdmin ? Icons.shield_outlined : Icons.person_outline, + color: user.isAdmin + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSecondaryContainer, + size: 20, + ), + ), + title: Text(user.displayName), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(user.email, style: theme.textTheme.bodySmall), + Row( + children: [ + Chip( + label: Text(user.role), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + labelStyle: theme.textTheme.labelSmall, + ), + if (user.isPremium) ...[ + const SizedBox(width: 4), + Chip( + label: const Text('Premium'), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + labelStyle: theme.textTheme.labelSmall, + backgroundColor: theme.colorScheme.tertiaryContainer, + ), + ], + ], + ), + ], + ), + isThreeLine: true, + trailing: PopupMenuButton( + onSelected: (action) { + switch (action) { + case 'role': + onChangeRole(); + case 'reset': + onResetPassword(); + case 'delete': + onDelete(); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'role', + child: Text( + user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin', + ), + ), + const PopupMenuItem( + value: 'reset', + child: Text('Återställ lösenord'), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'delete', + child: Text( + 'Ta bort', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ), + ); + } +} + +class _CreateUserDialog extends StatefulWidget { + const _CreateUserDialog(); + + @override + State<_CreateUserDialog> createState() => _CreateUserDialogState(); +} + +class _CreateUserDialogState extends State<_CreateUserDialog> { + final _formKey = GlobalKey(); + final _usernameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + String _role = 'user'; + bool _obscure = true; + + @override + void dispose() { + _usernameCtrl.dispose(); + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Skapa användare'), + content: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _usernameCtrl, + decoration: const InputDecoration(labelText: 'Användarnamn'), + validator: (v) => + (v == null || v.length < 2) ? 'Minst 2 tecken' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _emailCtrl, + decoration: const InputDecoration(labelText: 'E-post'), + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.isEmpty) return 'Obligatoriskt'; + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) { + return 'Ogiltig e-post'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordCtrl, + decoration: InputDecoration( + labelText: 'Lösenord', + suffixIcon: IconButton( + icon: Icon( + _obscure + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + obscureText: _obscure, + validator: (v) => + (v == null || v.length < 8) ? 'Minst 8 tecken' : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _role, + decoration: const InputDecoration(labelText: 'Roll'), + items: const [ + DropdownMenuItem(value: 'user', child: Text('Användare')), + DropdownMenuItem(value: 'admin', child: Text('Admin')), + ], + onChanged: (v) => setState(() => _role = v ?? 'user'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + Navigator.pop(context, { + 'username': _usernameCtrl.text.trim(), + 'email': _emailCtrl.text.trim(), + 'password': _passwordCtrl.text, + 'role': _role, + }); + } + }, + child: const Text('Skapa'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/profile/presentation/profile_screen.dart b/flutter/lib/features/profile/presentation/profile_screen.dart index 69edc1ea..d160b55d 100644 --- a/flutter/lib/features/profile/presentation/profile_screen.dart +++ b/flutter/lib/features/profile/presentation/profile_screen.dart @@ -1,10 +1,19 @@ 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 '../../admin/presentation/admin_ai_panel.dart'; +import '../../admin/presentation/admin_pending_products_panel.dart'; +import '../../admin/presentation/admin_users_panel.dart'; import '../../auth/data/auth_providers.dart'; import '../data/profile_repository.dart'; import '../domain/user_profile.dart'; +enum _ProfileTab { profile, database, users, suggestions, ai } + +enum _DatabaseTab { inventory, pantry, products } + class ProfileScreen extends ConsumerStatefulWidget { const ProfileScreen({super.key}); @@ -18,6 +27,8 @@ class _ProfileScreenState extends ConsumerState { bool _isSaving = false; String? _error; UserProfile? _profile; + _ProfileTab _activeTab = _ProfileTab.profile; + _DatabaseTab _activeDatabaseTab = _DatabaseTab.inventory; late final TextEditingController _emailCtrl; late final TextEditingController _firstNameCtrl; @@ -88,98 +99,367 @@ class _ProfileScreenState extends ConsumerState { Future _logout() async { await ref.read(authStateProvider.notifier).logout(); + if (!mounted) return; + context.go('/login'); + } + + List<_ProfileTab> _visibleTabs(bool isAdmin) { + return [ + _ProfileTab.profile, + _ProfileTab.database, + if (isAdmin) ...[ + _ProfileTab.users, + _ProfileTab.suggestions, + _ProfileTab.ai, + ], + ]; + } + + String _tabLabel(_ProfileTab tab) { + switch (tab) { + case _ProfileTab.profile: + return 'Min profil'; + case _ProfileTab.database: + return 'Databas'; + case _ProfileTab.users: + return 'Användare'; + case _ProfileTab.suggestions: + return 'Förslag'; + case _ProfileTab.ai: + return 'AI'; + } + } + + Widget _buildTabBar(BuildContext context, List<_ProfileTab> tabs) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: tabs + .map( + (tab) => Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(_tabLabel(tab)), + selected: _activeTab == tab, + onSelected: (_) => setState(() => _activeTab = tab), + ), + ), + ) + .toList(), + ), + ); + } + + Widget _buildProfileForm(BuildContext context, ThemeData theme) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Användarnamn', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text(_profile?.username ?? '', style: theme.textTheme.bodyLarge), + const Divider(height: 32), + TextFormField( + controller: _emailCtrl, + decoration: const InputDecoration( + labelText: 'E-post', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + validator: (v) { + if (v == null || v.isEmpty) return 'Ange en e-postadress'; + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) { + return 'Ogiltig e-postadress'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _firstNameCtrl, + decoration: const InputDecoration( + labelText: 'Förnamn', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _lastNameCtrl, + decoration: const InputDecoration( + labelText: 'Efternamn', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Spara ändringar'), + ), + ), + ], + ), + ); + } + + Widget _buildDatabaseTab(BuildContext context) { + final isAdmin = _profile?.isAdmin == true; + final visibleTabs = [ + _DatabaseTab.inventory, + _DatabaseTab.pantry, + if (isAdmin) _DatabaseTab.products, + ]; + if (!visibleTabs.contains(_activeDatabaseTab)) { + _activeDatabaseTab = _DatabaseTab.inventory; + } + + String tabLabel(_DatabaseTab tab) { + switch (tab) { + case _DatabaseTab.inventory: + return 'Inventarie'; + case _DatabaseTab.pantry: + return 'Baslager'; + case _DatabaseTab.products: + return 'Produkter'; + } + } + + Widget sectionCard({ + required IconData icon, + required String title, + required String description, + required VoidCallback onPressed, + required String buttonLabel, + }) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + Text(description), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(buttonLabel), + ), + ], + ), + ), + ); + } + + Widget activeSection; + switch (_activeDatabaseTab) { + case _DatabaseTab.inventory: + activeSection = sectionCard( + icon: Icons.inventory_2_outlined, + title: 'Inventarie', + description: + 'Lägg till, uppdatera och konsumera varor i ditt inventarie. Detta motsvarar inventarievyn under Databas i recipe-frontend.', + onPressed: () => context.go('/inventory'), + buttonLabel: 'Öppna inventarie', + ); + case _DatabaseTab.pantry: + activeSection = sectionCard( + icon: Icons.storefront_outlined, + title: 'Baslager', + description: + 'Hantera varor du alltid räknar med att ha hemma. Detta motsvarar baslagervyn under Databas i recipe-frontend.', + onPressed: () => context.go('/baslager'), + buttonLabel: 'Öppna baslager', + ); + case _DatabaseTab.products: + activeSection = sectionCard( + icon: Icons.category_outlined, + title: 'Produkter', + description: + 'Adminhantering av produktkatalogen, inklusive standardisering och vidare produktadministration.', + onPressed: () => context.go('/admin'), + buttonLabel: 'Öppna Admin', + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Databasfliken samlar samma huvudområden som i recipe-frontend.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: visibleTabs + .map( + (tab) => Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(tabLabel(tab)), + selected: _activeDatabaseTab == tab, + onSelected: (_) => setState(() => _activeDatabaseTab = tab), + ), + ), + ) + .toList(), + ), + ), + const SizedBox(height: 16), + activeSection, + ], + ); + } + + Widget _buildAdminPlaceholder( + BuildContext context, { + required String title, + required String description, + }) { + return 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), + FilledButton.icon( + onPressed: () => context.go('/admin'), + icon: const Icon(Icons.admin_panel_settings_outlined), + label: const Text('Öppna Admin'), + ), + ], + ), + ), + ); + } + + Widget _buildActiveTabContent(BuildContext context, ThemeData theme) { + switch (_activeTab) { + case _ProfileTab.profile: + return _buildProfileForm(context, theme); + case _ProfileTab.database: + return _buildDatabaseTab(context); + case _ProfileTab.users: + return const AdminUsersPanel(embedded: true); + case _ProfileTab.suggestions: + return const AdminPendingProductsPanel(embedded: true); + case _ProfileTab.ai: + return const AdminAiPanel(embedded: true); + } } @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - title: const Text('Profil'), - actions: [], // Utloggningsikonen tas bort här eftersom den redan finns i AppShell - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_error!, style: TextStyle(color: theme.colorScheme.error)), - const SizedBox(height: 16), - FilledButton(onPressed: _loadProfile, child: const Text('Försök igen')), - ], + final tabs = _visibleTabs(_profile?.isAdmin == true); + if (!tabs.contains(_activeTab)) { + _activeTab = _ProfileTab.profile; + } + + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + const SizedBox(height: 16), + FilledButton(onPressed: _loadProfile, child: const Text('Försök igen')), + ], + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + CircleAvatar( + radius: 28, + child: Text( + (_profile?.username.isNotEmpty == true + ? _profile!.username[0] + : '?') + .toUpperCase(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _profile?.username ?? '', + style: theme.textTheme.titleLarge, ), - ) - : SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Read-only username - Text('Användarnamn', style: theme.textTheme.labelMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant)), - const SizedBox(height: 4), - Text(_profile?.username ?? '', style: theme.textTheme.bodyLarge), - const Divider(height: 32), - // Role badge - if (_profile?.isAdmin == true) - Chip( - label: const Text('Admin'), - avatar: const Icon(Icons.shield_outlined, size: 16), - backgroundColor: theme.colorScheme.primaryContainer, - labelStyle: TextStyle(color: theme.colorScheme.onPrimaryContainer), - ), - if (_profile?.isAdmin == true) const SizedBox(height: 16), - // Editable fields - TextFormField( - controller: _emailCtrl, - decoration: const InputDecoration( - labelText: 'E-post', - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.emailAddress, - validator: (v) { - if (v == null || v.isEmpty) return 'Ange en e-postadress'; - if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) return 'Ogiltig e-postadress'; - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _firstNameCtrl, - decoration: const InputDecoration( - labelText: 'Förnamn', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _lastNameCtrl, - decoration: const InputDecoration( - labelText: 'Efternamn', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 32), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _isSaving ? null : _save, - child: _isSaving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Spara'), - ), - ), - ], + if ((_profile?.email ?? '').isNotEmpty) + Text( + _profile!.email, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - ), + ], + ), + ), + if (_profile?.isAdmin == true) + Chip( + label: const Text('Admin'), + avatar: const Icon(Icons.shield_outlined, size: 16), + backgroundColor: theme.colorScheme.primaryContainer, + labelStyle: TextStyle(color: theme.colorScheme.onPrimaryContainer), + ), + ], + ), + const SizedBox(height: 16), + _buildTabBar(context, tabs), + const SizedBox(height: 16), + _buildActiveTabContent(context, theme), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _logout, + icon: const Icon(Icons.logout), + label: const Text('Logga ut'), + ), + ), + ], ); } }