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.
This commit is contained in:
Nils-Johan Gynther
2026-04-25 08:22:14 +02:00
parent 53afcc98a9
commit 8ea2b97c27
10 changed files with 1289 additions and 488 deletions
@@ -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<AdminRepository>((ref) {
@@ -80,4 +82,37 @@ class AdminRepository {
);
return (result as Map<String, dynamic>);
}
Future<List<PendingProduct>> listPendingProducts() async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(ProductApiPaths.pending, token: token),
);
return (data as List<dynamic>)
.map((e) => PendingProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> setProductStatus(int productId, String status) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.patchJson(
ProductApiPaths.setStatus(productId),
body: {'status': status},
token: token,
),
);
}
Future<List<AiModelInfo>> listAiModels() async {
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(AiApiPaths.models),
);
return (data as List<dynamic>)
.map((e) => AiModelInfo.fromJson(e as Map<String, dynamic>))
.toList();
}
}
@@ -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<String, dynamic> 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(),
);
}
@@ -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<String, dynamic> json) {
final categoryRef = json['categoryRef'];
final owner = json['owner'];
final parts = <String>[];
if (categoryRef is Map<String, dynamic>) {
final parent = categoryRef['parent'];
if (parent is Map<String, dynamic>) {
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<String, dynamic>
? owner['username']?.toString()
: null,
);
}
}
@@ -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<AdminAiPanel> createState() => _AdminAiPanelState();
}
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
bool _isLoading = true;
String? _error;
List<AiModelInfo> _models = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _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),
],
),
),
),
),
],
);
}
}
@@ -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<AdminPendingProductsPanel> createState() =>
_AdminPendingProductsPanelState();
}
class _AdminPendingProductsPanelState
extends ConsumerState<AdminPendingProductsPanel> {
bool _isLoading = true;
String? _error;
int? _processingId;
List<PendingProduct> _products = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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,
],
);
}
}
@@ -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<AdminScreen> {
bool _isLoading = true;
String? _error;
List<UserAdmin> _users = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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<void> _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<void>(
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<void> _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<void> _createUser() async {
final result = await showDialog<Map<String, String>>(
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<bool> _confirm(BuildContext ctx, String title, String body, {bool destructive = false}) async {
final result = await showDialog<bool>(
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<String>(
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<FormState>();
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<String>(
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(),
);
}
}
@@ -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<AdminUsersPanel> createState() => _AdminUsersPanelState();
}
class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
bool _isLoading = true;
String? _error;
List<UserAdmin> _users = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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<void> _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<void>(
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<void> _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<void> _createUser() async {
final result = await showDialog<Map<String, String>>(
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<bool> _confirm(
BuildContext ctx,
String title,
String body, {
bool destructive = false,
}) async {
final result = await showDialog<bool>(
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<String>(
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<FormState>();
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<String>(
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'),
),
],
);
}
}
@@ -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<ProfileScreen> {
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<ProfileScreen> {
Future<void> _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'),
),
),
],
);
}
}