feat: add Flutter web frontend with authentication and recipe management features

This commit is contained in:
Nils-Johan Gynther
2026-04-21 21:29:47 +02:00
parent 2acf66e4c4
commit 3996456f6f
19 changed files with 460 additions and 0 deletions
@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/platform/platform_providers.dart';
import 'auth_repository.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(
ref.watch(apiClientProvider),
ref.watch(tokenStorageProvider),
);
});
final authStateProvider = AsyncNotifierProvider<AuthNotifier, String?>(() {
return AuthNotifier();
});
class AuthNotifier extends AsyncNotifier<String?> {
@override
Future<String?> build() async {
return ref.watch(authRepositoryProvider).getToken();
}
Future<void> login(String email, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(authRepositoryProvider).login(email, password),
);
}
Future<void> logout() async {
await ref.read(authRepositoryProvider).logout();
state = const AsyncData(null);
}
}
@@ -0,0 +1,28 @@
import 'dart:convert';
import '../../core/api/api_client.dart';
import '../../core/platform/token_storage.dart';
class AuthRepository {
final ApiClient _api;
final ITokenStorage _storage;
AuthRepository(this._api, this._storage);
Future<String> login(String email, String password) async {
final response = await _api.post(
'/api/auth/login',
jsonEncode({'email': email, 'password': password}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception('Login failed: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final token = data['access_token'] as String;
await _storage.saveToken(token);
return token;
}
Future<void> logout() => _storage.deleteToken();
Future<String?> getToken() => _storage.getToken();
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/auth_providers.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
await ref.read(authStateProvider.notifier).login(
_emailCtrl.text.trim(),
_passwordCtrl.text,
);
if (mounted) {
final state = ref.read(authStateProvider);
if (state is AsyncData && state.value != null) {
if (context.mounted) Navigator.of(context).pushReplacementNamed('/recipes');
}
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authStateProvider);
return Scaffold(
appBar: AppBar(title: const Text('Logga in')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'E-post'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
TextField(
controller: _passwordCtrl,
decoration: const InputDecoration(labelText: 'Lösenord'),
obscureText: true,
),
const SizedBox(height: 24),
if (authState is AsyncLoading)
const CircularProgressIndicator()
else
ElevatedButton(
onPressed: _submit,
child: const Text('Logga in'),
),
if (authState is AsyncError)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'Inloggning misslyckades',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
),
);
}
}