From d8164be49a09ecfda2e4dbd7b0f76628b8dcb42e Mon Sep 17 00:00:00 2001 From: m3mo Date: Mon, 2 Feb 2026 22:58:07 +0100 Subject: [PATCH] Add user authentication to Flutter frontend - Create auth feature with Clean Architecture (domain/data/presentation) - Add login and register pages with form validation - Implement secure token storage with flutter_secure_storage - Create AuthenticatedClient for automatic Bearer token headers - Add AuthViewModel for global auth state management - Update router with auth guards (redirect to login if not authenticated) - Add logout option to settings page - Update TaskRemoteDataSource to use authenticated client - Add auth-related localization strings (EN/DE) --- lib/app.dart | 2 + lib/core/di/injection_container.dart | 51 +++- lib/core/errors/exceptions.dart | 18 ++ lib/core/errors/failures.dart | 8 + lib/core/network/authenticated_client.dart | 68 +++++ .../datasources/auth_local_datasource.dart | 103 ++++++++ .../datasources/auth_remote_datasource.dart | 179 +++++++++++++ .../auth/data/models/token_model.dart | 27 ++ lib/features/auth/data/models/user_model.dart | 39 +++ .../repositories/auth_repository_impl.dart | 153 +++++++++++ .../auth/domain/entities/user_entity.dart | 36 +++ .../domain/repositories/auth_repository.dart | 25 ++ .../auth/presentation/pages/login_page.dart | 190 ++++++++++++++ .../presentation/pages/register_page.dart | 244 ++++++++++++++++++ .../viewmodels/auth_viewmodel.dart | 160 ++++++++++++ .../presentation/pages/settings_page.dart | 45 ++++ .../datasources/task_remote_datasource.dart | 10 +- lib/l10n/app_de.arb | 20 +- lib/l10n/app_en.arb | 20 +- lib/l10n/app_localizations.dart | 108 ++++++++ lib/l10n/app_localizations_de.dart | 54 ++++ lib/l10n/app_localizations_en.dart | 54 ++++ lib/routing/app_router.dart | 122 ++++++--- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 124 ++++++++- pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 30 files changed, 1823 insertions(+), 49 deletions(-) create mode 100644 lib/core/network/authenticated_client.dart create mode 100644 lib/features/auth/data/datasources/auth_local_datasource.dart create mode 100644 lib/features/auth/data/datasources/auth_remote_datasource.dart create mode 100644 lib/features/auth/data/models/token_model.dart create mode 100644 lib/features/auth/data/models/user_model.dart create mode 100644 lib/features/auth/data/repositories/auth_repository_impl.dart create mode 100644 lib/features/auth/domain/entities/user_entity.dart create mode 100644 lib/features/auth/domain/repositories/auth_repository.dart create mode 100644 lib/features/auth/presentation/pages/login_page.dart create mode 100644 lib/features/auth/presentation/pages/register_page.dart create mode 100644 lib/features/auth/presentation/viewmodels/auth_viewmodel.dart diff --git a/lib/app.dart b/lib/app.dart index 6c60f11..33cd80c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,6 +4,7 @@ import 'package:agenda_tasks/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'core/di/injection_container.dart'; +import 'features/auth/presentation/viewmodels/auth_viewmodel.dart'; import 'features/settings/presentation/viewmodels/settings_viewmodel.dart'; import 'routing/app_router.dart'; @@ -14,6 +15,7 @@ class AgendaApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider.value(value: getIt()), ChangeNotifierProvider(create: (_) => getIt()), ], child: Consumer( diff --git a/lib/core/di/injection_container.dart b/lib/core/di/injection_container.dart index 0ed0cf3..ff5339d 100644 --- a/lib/core/di/injection_container.dart +++ b/lib/core/di/injection_container.dart @@ -1,6 +1,13 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; +import '../../features/auth/data/datasources/auth_local_datasource.dart'; +import '../../features/auth/data/datasources/auth_remote_datasource.dart'; +import '../../features/auth/data/repositories/auth_repository_impl.dart'; +import '../../features/auth/domain/repositories/auth_repository.dart'; +import '../../features/auth/presentation/viewmodels/auth_viewmodel.dart'; import '../../features/settings/presentation/viewmodels/settings_viewmodel.dart'; import '../../features/tasks/presentation/viewmodels/daily_tasks_viewmodel.dart'; import '../../features/tasks/presentation/viewmodels/task_form_viewmodel.dart'; @@ -9,6 +16,7 @@ import '../../features/tasks/data/repositories/task_repository_impl.dart'; import '../../features/tasks/data/datasources/task_remote_datasource.dart'; import '../../features/settings/data/settings_local_datasource.dart'; import '../logging/app_logger.dart'; +import '../network/authenticated_client.dart'; final getIt = GetIt.instance; @@ -17,12 +25,51 @@ Future init() async { final sharedPreferences = await SharedPreferences.getInstance(); getIt.registerSingleton(sharedPreferences); + const flutterSecureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + getIt.registerSingleton(flutterSecureStorage); + // Logger getIt.registerSingleton(AppLogger()); - // Data sources + // Auth Data sources + getIt.registerLazySingleton( + () => AuthLocalDataSourceImpl(storage: getIt()), + ); + getIt.registerLazySingleton( + () => AuthRemoteDataSourceImpl(logger: getIt()), + ); + + // Auth Repository + getIt.registerLazySingleton( + () => AuthRepositoryImpl( + remoteDataSource: getIt(), + localDataSource: getIt(), + logger: getIt(), + ), + ); + + // Auth ViewModel (singleton for global state) + getIt.registerSingleton( + AuthViewModel(repository: getIt(), logger: getIt()), + ); + + // Authenticated HTTP Client + getIt.registerLazySingleton( + () => AuthenticatedClient( + localDataSource: getIt(), + authRepository: getIt(), + ), + ); + + // Task Data sources getIt.registerLazySingleton( - () => TaskRemoteDataSourceImpl(logger: getIt()), + () => TaskRemoteDataSourceImpl( + logger: getIt(), + client: getIt(), + ), ); getIt.registerLazySingleton( () => SettingsLocalDataSourceImpl(sharedPreferences: getIt()), diff --git a/lib/core/errors/exceptions.dart b/lib/core/errors/exceptions.dart index 1d95a8a..642d56e 100644 --- a/lib/core/errors/exceptions.dart +++ b/lib/core/errors/exceptions.dart @@ -35,3 +35,21 @@ class NotFoundException implements Exception { @override String toString() => 'NotFoundException: $message'; } + +class AuthException implements Exception { + final String message; + + const AuthException({required this.message}); + + @override + String toString() => 'AuthException: $message'; +} + +class UnauthorizedException implements Exception { + final String message; + + const UnauthorizedException({this.message = 'Unauthorized'}); + + @override + String toString() => 'UnauthorizedException: $message'; +} diff --git a/lib/core/errors/failures.dart b/lib/core/errors/failures.dart index 7e81142..e3d360c 100644 --- a/lib/core/errors/failures.dart +++ b/lib/core/errors/failures.dart @@ -27,3 +27,11 @@ class NotFoundFailure extends Failure { class UnexpectedFailure extends Failure { const UnexpectedFailure({super.message = 'An unexpected error occurred', super.code}); } + +class AuthFailure extends Failure { + const AuthFailure({required super.message, super.code}); +} + +class UnauthorizedFailure extends Failure { + const UnauthorizedFailure({super.message = 'Unauthorized', super.code}); +} diff --git a/lib/core/network/authenticated_client.dart b/lib/core/network/authenticated_client.dart new file mode 100644 index 0000000..454a6f8 --- /dev/null +++ b/lib/core/network/authenticated_client.dart @@ -0,0 +1,68 @@ +import 'package:http/http.dart' as http; + +import '../../features/auth/data/datasources/auth_local_datasource.dart'; +import '../../features/auth/domain/repositories/auth_repository.dart'; +import '../errors/exceptions.dart'; + +class AuthenticatedClient extends http.BaseClient { + final http.Client _inner; + final AuthLocalDataSource _localDataSource; + final AuthRepository _authRepository; + + AuthenticatedClient({ + required AuthLocalDataSource localDataSource, + required AuthRepository authRepository, + http.Client? inner, + }) : _localDataSource = localDataSource, + _authRepository = authRepository, + _inner = inner ?? http.Client(); + + @override + Future send(http.BaseRequest request) async { + final tokens = await _localDataSource.getTokens(); + if (tokens != null) { + request.headers['Authorization'] = 'Bearer ${tokens.accessToken}'; + } + + var response = await _inner.send(request); + + if (response.statusCode == 401 && tokens != null) { + final refreshResult = await _authRepository.refreshToken(); + final refreshed = refreshResult.when( + success: (_) => true, + error: (_) => false, + ); + + if (refreshed) { + final newTokens = await _localDataSource.getTokens(); + if (newTokens != null) { + final newRequest = _copyRequest(request, newTokens.accessToken); + response = await _inner.send(newRequest); + } + } else { + await _localDataSource.clearAll(); + throw const UnauthorizedException(message: 'Session expired'); + } + } + + return response; + } + + http.BaseRequest _copyRequest(http.BaseRequest original, String token) { + final http.Request newRequest = http.Request(original.method, original.url) + ..headers.addAll(original.headers) + ..headers['Authorization'] = 'Bearer $token'; + + if (original is http.Request && original.body.isNotEmpty) { + newRequest.body = original.body; + } + + return newRequest; + } + + @override + void close() { + _inner.close(); + super.close(); + } +} diff --git a/lib/features/auth/data/datasources/auth_local_datasource.dart b/lib/features/auth/data/datasources/auth_local_datasource.dart new file mode 100644 index 0000000..4d2d7ab --- /dev/null +++ b/lib/features/auth/data/datasources/auth_local_datasource.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../models/token_model.dart'; +import '../models/user_model.dart'; + +abstract class AuthLocalDataSource { + Future saveTokens(TokenModel tokens); + Future getTokens(); + Future deleteTokens(); + Future saveUser(UserModel user); + Future getUser(); + Future deleteUser(); + Future clearAll(); + Stream get authStateChanges; + void notifyAuthStateChanged(bool isAuthenticated); +} + +class AuthLocalDataSourceImpl implements AuthLocalDataSource { + final FlutterSecureStorage _storage; + final _authStateController = StreamController.broadcast(); + + static const String _accessTokenKey = 'access_token'; + static const String _refreshTokenKey = 'refresh_token'; + static const String _tokenTypeKey = 'token_type'; + static const String _userKey = 'current_user'; + + AuthLocalDataSourceImpl({required FlutterSecureStorage storage}) + : _storage = storage; + + @override + Future saveTokens(TokenModel tokens) async { + await Future.wait([ + _storage.write(key: _accessTokenKey, value: tokens.accessToken), + _storage.write(key: _refreshTokenKey, value: tokens.refreshToken), + _storage.write(key: _tokenTypeKey, value: tokens.tokenType), + ]); + } + + @override + Future getTokens() async { + final accessToken = await _storage.read(key: _accessTokenKey); + final refreshToken = await _storage.read(key: _refreshTokenKey); + final tokenType = await _storage.read(key: _tokenTypeKey); + + if (accessToken == null || refreshToken == null) { + return null; + } + + return TokenModel( + accessToken: accessToken, + refreshToken: refreshToken, + tokenType: tokenType ?? 'bearer', + ); + } + + @override + Future deleteTokens() async { + await Future.wait([ + _storage.delete(key: _accessTokenKey), + _storage.delete(key: _refreshTokenKey), + _storage.delete(key: _tokenTypeKey), + ]); + } + + @override + Future saveUser(UserModel user) async { + await _storage.write(key: _userKey, value: json.encode(user.toJson())); + } + + @override + Future getUser() async { + final userJson = await _storage.read(key: _userKey); + if (userJson == null) { + return null; + } + return UserModel.fromJson(json.decode(userJson)); + } + + @override + Future deleteUser() async { + await _storage.delete(key: _userKey); + } + + @override + Future clearAll() async { + await Future.wait([ + deleteTokens(), + deleteUser(), + ]); + notifyAuthStateChanged(false); + } + + @override + Stream get authStateChanges => _authStateController.stream; + + @override + void notifyAuthStateChanged(bool isAuthenticated) { + _authStateController.add(isAuthenticated); + } +} diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.dart b/lib/features/auth/data/datasources/auth_remote_datasource.dart new file mode 100644 index 0000000..3aec41c --- /dev/null +++ b/lib/features/auth/data/datasources/auth_remote_datasource.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../../../../core/errors/exceptions.dart'; +import '../../../../core/logging/app_logger.dart'; +import '../models/token_model.dart'; +import '../models/user_model.dart'; + +abstract class AuthRemoteDataSource { + Future register({ + required String email, + required String password, + required String name, + }); + + Future login({ + required String email, + required String password, + }); + + Future getCurrentUser(String accessToken); + + Future refreshToken(String refreshToken); +} + +class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { + final AppLogger logger; + final http.Client _client; + + static const String _baseUrl = String.fromEnvironment( + 'API_URL', + defaultValue: 'http://localhost:8000', + ); + + AuthRemoteDataSourceImpl({ + required this.logger, + http.Client? client, + }) : _client = client ?? http.Client(); + + Map get _headers => { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + Map _authHeaders(String token) => { + ..._headers, + 'Authorization': 'Bearer $token', + }; + + @override + Future register({ + required String email, + required String password, + required String name, + }) async { + final uri = Uri.parse('$_baseUrl/auth/register'); + logger.info('POST $uri'); + + try { + final response = await _client.post( + uri, + headers: _headers, + body: json.encode({ + 'email': email, + 'password': password, + 'name': name, + }), + ); + logger.debug('Response: ${response.statusCode}'); + + if (response.statusCode == 201 || response.statusCode == 200) { + return UserModel.fromJson(json.decode(response.body)); + } else if (response.statusCode == 400) { + final body = json.decode(response.body); + throw AuthException( + message: body['detail'] ?? 'Registration failed', + ); + } else { + throw ServerException( + message: 'Registration failed', + statusCode: response.statusCode, + ); + } + } on SocketException { + throw const NetworkException(); + } + } + + @override + Future login({ + required String email, + required String password, + }) async { + final uri = Uri.parse('$_baseUrl/auth/login'); + logger.info('POST $uri'); + + try { + final response = await _client.post( + uri, + headers: _headers, + body: json.encode({ + 'email': email, + 'password': password, + }), + ); + logger.debug('Response: ${response.statusCode}'); + + if (response.statusCode == 200) { + return TokenModel.fromJson(json.decode(response.body)); + } else if (response.statusCode == 401) { + throw const AuthException(message: 'Invalid email or password'); + } else { + throw ServerException( + message: 'Login failed', + statusCode: response.statusCode, + ); + } + } on SocketException { + throw const NetworkException(); + } + } + + @override + Future getCurrentUser(String accessToken) async { + final uri = Uri.parse('$_baseUrl/auth/me'); + logger.info('GET $uri'); + + try { + final response = await _client.get( + uri, + headers: _authHeaders(accessToken), + ); + logger.debug('Response: ${response.statusCode}'); + + if (response.statusCode == 200) { + return UserModel.fromJson(json.decode(response.body)); + } else if (response.statusCode == 401) { + throw const UnauthorizedException(); + } else { + throw ServerException( + message: 'Failed to get user info', + statusCode: response.statusCode, + ); + } + } on SocketException { + throw const NetworkException(); + } + } + + @override + Future refreshToken(String refreshToken) async { + final uri = Uri.parse('$_baseUrl/auth/refresh'); + logger.info('POST $uri'); + + try { + final response = await _client.post( + uri, + headers: _headers, + body: json.encode({'refresh_token': refreshToken}), + ); + logger.debug('Response: ${response.statusCode}'); + + if (response.statusCode == 200) { + return TokenModel.fromJson(json.decode(response.body)); + } else if (response.statusCode == 401) { + throw const UnauthorizedException(message: 'Session expired'); + } else { + throw ServerException( + message: 'Token refresh failed', + statusCode: response.statusCode, + ); + } + } on SocketException { + throw const NetworkException(); + } + } +} diff --git a/lib/features/auth/data/models/token_model.dart b/lib/features/auth/data/models/token_model.dart new file mode 100644 index 0000000..8f7e3fb --- /dev/null +++ b/lib/features/auth/data/models/token_model.dart @@ -0,0 +1,27 @@ +class TokenModel { + final String accessToken; + final String refreshToken; + final String tokenType; + + const TokenModel({ + required this.accessToken, + required this.refreshToken, + this.tokenType = 'bearer', + }); + + factory TokenModel.fromJson(Map json) { + return TokenModel( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + tokenType: json['token_type'] as String? ?? 'bearer', + ); + } + + Map toJson() { + return { + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'token_type': tokenType, + }; + } +} diff --git a/lib/features/auth/data/models/user_model.dart b/lib/features/auth/data/models/user_model.dart new file mode 100644 index 0000000..8922ef5 --- /dev/null +++ b/lib/features/auth/data/models/user_model.dart @@ -0,0 +1,39 @@ +import '../../domain/entities/user_entity.dart'; + +class UserModel extends UserEntity { + const UserModel({ + required super.id, + required super.email, + required super.name, + super.createdAt, + }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id'] as String, + email: json['email'] as String, + name: json['name'] as String, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'email': email, + 'name': name, + 'created_at': createdAt?.toIso8601String(), + }; + } + + factory UserModel.fromEntity(UserEntity entity) { + return UserModel( + id: entity.id, + email: entity.email, + name: entity.name, + createdAt: entity.createdAt, + ); + } +} diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..e9d6ab1 --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,153 @@ +import '../../../../core/errors/exceptions.dart'; +import '../../../../core/errors/failures.dart'; +import '../../../../core/errors/result.dart'; +import '../../../../core/logging/app_logger.dart'; +import '../../domain/entities/user_entity.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../datasources/auth_local_datasource.dart'; +import '../datasources/auth_remote_datasource.dart'; + +class AuthRepositoryImpl implements AuthRepository { + final AuthRemoteDataSource remoteDataSource; + final AuthLocalDataSource localDataSource; + final AppLogger logger; + + AuthRepositoryImpl({ + required this.remoteDataSource, + required this.localDataSource, + required this.logger, + }); + + @override + Future> register({ + required String email, + required String password, + required String name, + }) async { + try { + final user = await remoteDataSource.register( + email: email, + password: password, + name: name, + ); + return Success(user); + } on AuthException catch (e) { + logger.error('Registration failed: ${e.message}'); + return Error(AuthFailure(message: e.message)); + } on NetworkException { + logger.error('Network error during registration'); + return const Error(NetworkFailure()); + } on ServerException catch (e) { + logger.error('Server error during registration: ${e.message}'); + return Error(ServerFailure(message: e.message)); + } catch (e) { + logger.error('Unexpected error during registration: $e'); + return const Error(UnexpectedFailure()); + } + } + + @override + Future> login({ + required String email, + required String password, + }) async { + try { + final tokens = await remoteDataSource.login( + email: email, + password: password, + ); + await localDataSource.saveTokens(tokens); + + final user = await remoteDataSource.getCurrentUser(tokens.accessToken); + await localDataSource.saveUser(user); + localDataSource.notifyAuthStateChanged(true); + + return Success(user); + } on AuthException catch (e) { + logger.error('Login failed: ${e.message}'); + return Error(AuthFailure(message: e.message)); + } on NetworkException { + logger.error('Network error during login'); + return const Error(NetworkFailure()); + } on ServerException catch (e) { + logger.error('Server error during login: ${e.message}'); + return Error(ServerFailure(message: e.message)); + } catch (e) { + logger.error('Unexpected error during login: $e'); + return const Error(UnexpectedFailure()); + } + } + + @override + Future> logout() async { + try { + await localDataSource.clearAll(); + return const Success(null); + } catch (e) { + logger.error('Error during logout: $e'); + return const Error(UnexpectedFailure()); + } + } + + @override + Future> getCurrentUser() async { + try { + final cachedUser = await localDataSource.getUser(); + if (cachedUser != null) { + return Success(cachedUser); + } + + final tokens = await localDataSource.getTokens(); + if (tokens == null) { + return const Error(UnauthorizedFailure()); + } + + final user = await remoteDataSource.getCurrentUser(tokens.accessToken); + await localDataSource.saveUser(user); + return Success(user); + } on UnauthorizedException { + await localDataSource.clearAll(); + return const Error(UnauthorizedFailure()); + } on NetworkException { + final cachedUser = await localDataSource.getUser(); + if (cachedUser != null) { + return Success(cachedUser); + } + return const Error(NetworkFailure()); + } catch (e) { + logger.error('Error getting current user: $e'); + return const Error(UnexpectedFailure()); + } + } + + @override + Future> refreshToken() async { + try { + final tokens = await localDataSource.getTokens(); + if (tokens == null) { + return const Error(UnauthorizedFailure()); + } + + final newTokens = await remoteDataSource.refreshToken(tokens.refreshToken); + await localDataSource.saveTokens(newTokens); + return const Success(null); + } on UnauthorizedException { + await localDataSource.clearAll(); + return const Error(UnauthorizedFailure(message: 'Session expired')); + } on NetworkException { + return const Error(NetworkFailure()); + } catch (e) { + logger.error('Error refreshing token: $e'); + return const Error(UnexpectedFailure()); + } + } + + @override + Future isAuthenticated() async { + final tokens = await localDataSource.getTokens(); + return tokens != null; + } + + @override + Stream get authStateChanges => localDataSource.authStateChanges; +} diff --git a/lib/features/auth/domain/entities/user_entity.dart b/lib/features/auth/domain/entities/user_entity.dart new file mode 100644 index 0000000..b7c8784 --- /dev/null +++ b/lib/features/auth/domain/entities/user_entity.dart @@ -0,0 +1,36 @@ +class UserEntity { + final String id; + final String email; + final String name; + final DateTime? createdAt; + + const UserEntity({ + required this.id, + required this.email, + required this.name, + this.createdAt, + }); + + UserEntity copyWith({ + String? id, + String? email, + String? name, + DateTime? createdAt, + }) { + return UserEntity( + id: id ?? this.id, + email: email ?? this.email, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UserEntity && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..c915202 --- /dev/null +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,25 @@ +import '../../../../core/errors/result.dart'; +import '../entities/user_entity.dart'; + +abstract class AuthRepository { + Future> register({ + required String email, + required String password, + required String name, + }); + + Future> login({ + required String email, + required String password, + }); + + Future> logout(); + + Future> getCurrentUser(); + + Future> refreshToken(); + + Future isAuthenticated(); + + Stream get authStateChanges; +} diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 0000000..6f3f2b5 --- /dev/null +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../../../../l10n/app_localizations.dart'; +import '../viewmodels/auth_viewmodel.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) return; + + final viewModel = context.read(); + final success = await viewModel.login( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + + if (mounted && success) { + context.go('/'); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.task_alt, + size: 80, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + l10n.appTitle, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.welcomeBack, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.email, + prefixIcon: const Icon(Icons.email_outlined), + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.emailRequired; + } + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(value)) { + return l10n.emailInvalid; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _login(), + decoration: InputDecoration( + labelText: l10n.password, + prefixIcon: const Icon(Icons.lock_outlined), + border: const OutlineInputBorder(), + suffixIcon: SizedBox( + width: 48, + height: 48, + child: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.passwordRequired; + } + return null; + }, + ), + const SizedBox(height: 8), + Consumer( + builder: (context, viewModel, _) { + if (viewModel.error != null) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + viewModel.error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + const SizedBox(height: 16), + Consumer( + builder: (context, viewModel, child) { + return SizedBox( + height: 56, + child: FilledButton( + onPressed: viewModel.isLoading ? null : _login, + child: viewModel.isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.login), + ), + ); + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.noAccountYet), + TextButton( + onPressed: () => context.go('/register'), + child: Text(l10n.register), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/pages/register_page.dart b/lib/features/auth/presentation/pages/register_page.dart new file mode 100644 index 0000000..03d817f --- /dev/null +++ b/lib/features/auth/presentation/pages/register_page.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../../../../l10n/app_localizations.dart'; +import '../viewmodels/auth_viewmodel.dart'; + +class RegisterPage extends StatefulWidget { + const RegisterPage({super.key}); + + @override + State createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _register() async { + if (!_formKey.currentState!.validate()) return; + + final viewModel = context.read(); + final l10n = AppLocalizations.of(context)!; + final success = await viewModel.register( + email: _emailController.text.trim(), + password: _passwordController.text, + name: _nameController.text.trim(), + ); + + if (mounted && success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.registerSuccess)), + ); + context.go('/login'); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/login'), + ), + ), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.register, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextFormField( + controller: _nameController, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.name, + prefixIcon: const Icon(Icons.person_outlined), + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.nameRequired; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.email, + prefixIcon: const Icon(Icons.email_outlined), + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.emailRequired; + } + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(value)) { + return l10n.emailInvalid; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.password, + prefixIcon: const Icon(Icons.lock_outlined), + border: const OutlineInputBorder(), + suffixIcon: SizedBox( + width: 48, + height: 48, + child: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.passwordRequired; + } + if (value.length < 8) { + return l10n.passwordTooShort; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _register(), + decoration: InputDecoration( + labelText: l10n.confirmPassword, + prefixIcon: const Icon(Icons.lock_outlined), + border: const OutlineInputBorder(), + suffixIcon: SizedBox( + width: 48, + height: 48, + child: IconButton( + icon: Icon( + _obscureConfirmPassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() => + _obscureConfirmPassword = !_obscureConfirmPassword); + }, + ), + ), + ), + validator: (value) { + if (value != _passwordController.text) { + return l10n.passwordsDoNotMatch; + } + return null; + }, + ), + const SizedBox(height: 8), + Consumer( + builder: (context, viewModel, _) { + if (viewModel.error != null) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + viewModel.error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + const SizedBox(height: 16), + Consumer( + builder: (context, viewModel, child) { + return SizedBox( + height: 56, + child: FilledButton( + onPressed: viewModel.isLoading ? null : _register, + child: viewModel.isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.register), + ), + ); + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.alreadyHaveAccount), + TextButton( + onPressed: () => context.go('/login'), + child: Text(l10n.login), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/viewmodels/auth_viewmodel.dart b/lib/features/auth/presentation/viewmodels/auth_viewmodel.dart new file mode 100644 index 0000000..5136fb7 --- /dev/null +++ b/lib/features/auth/presentation/viewmodels/auth_viewmodel.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../../../core/errors/failures.dart'; +import '../../../../core/logging/app_logger.dart'; +import '../../domain/entities/user_entity.dart'; +import '../../domain/repositories/auth_repository.dart'; + +enum AuthState { + initial, + loading, + authenticated, + unauthenticated, +} + +class AuthViewModel extends ChangeNotifier { + final AuthRepository repository; + final AppLogger logger; + + AuthState _state = AuthState.initial; + UserEntity? _user; + String? _error; + StreamSubscription? _authStateSubscription; + + AuthViewModel({ + required this.repository, + required this.logger, + }) { + _init(); + } + + AuthState get state => _state; + UserEntity? get user => _user; + String? get error => _error; + bool get isAuthenticated => _state == AuthState.authenticated; + bool get isLoading => _state == AuthState.loading; + + Future _init() async { + _authStateSubscription = repository.authStateChanges.listen((isAuth) { + if (!isAuth && _state == AuthState.authenticated) { + _state = AuthState.unauthenticated; + _user = null; + notifyListeners(); + } + }); + + await checkAuthStatus(); + } + + Future checkAuthStatus() async { + final isAuth = await repository.isAuthenticated(); + if (isAuth) { + final result = await repository.getCurrentUser(); + result.when( + success: (user) { + _user = user; + _state = AuthState.authenticated; + }, + error: (_) { + _state = AuthState.unauthenticated; + }, + ); + } else { + _state = AuthState.unauthenticated; + } + notifyListeners(); + } + + Future register({ + required String email, + required String password, + required String name, + }) async { + _state = AuthState.loading; + _error = null; + notifyListeners(); + + final result = await repository.register( + email: email, + password: password, + name: name, + ); + + return result.when( + success: (user) { + _state = AuthState.unauthenticated; + notifyListeners(); + return true; + }, + error: (failure) { + _error = _getErrorMessage(failure); + _state = AuthState.unauthenticated; + notifyListeners(); + return false; + }, + ); + } + + Future login({ + required String email, + required String password, + }) async { + _state = AuthState.loading; + _error = null; + notifyListeners(); + + final result = await repository.login( + email: email, + password: password, + ); + + return result.when( + success: (user) { + _user = user; + _state = AuthState.authenticated; + notifyListeners(); + return true; + }, + error: (failure) { + _error = _getErrorMessage(failure); + _state = AuthState.unauthenticated; + notifyListeners(); + return false; + }, + ); + } + + Future logout() async { + _state = AuthState.loading; + notifyListeners(); + + await repository.logout(); + _user = null; + _state = AuthState.unauthenticated; + notifyListeners(); + } + + void clearError() { + _error = null; + notifyListeners(); + } + + String _getErrorMessage(Failure failure) { + if (failure is AuthFailure) { + return failure.message; + } else if (failure is NetworkFailure) { + return 'No internet connection'; + } else if (failure is ServerFailure) { + return 'Server error. Please try again.'; + } + return 'An unexpected error occurred'; + } + + @override + void dispose() { + _authStateSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index a1d9da9..d37d57c 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -3,6 +3,7 @@ import 'package:agenda_tasks/l10n/app_localizations.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../../../auth/presentation/viewmodels/auth_viewmodel.dart'; import '../viewmodels/settings_viewmodel.dart'; class SettingsPage extends StatelessWidget { @@ -47,6 +48,24 @@ class SettingsPage extends StatelessWidget { title: Text(l10n.version), subtitle: const Text('1.0.0'), ), + const Divider(), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + height: 56, + child: OutlinedButton.icon( + onPressed: () => _showLogoutConfirmation(context, l10n), + icon: const Icon(Icons.logout), + label: Text(l10n.logout), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + side: BorderSide(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ), + const SizedBox(height: 32), ], ), ); @@ -90,6 +109,32 @@ class SettingsPage extends StatelessWidget { ), ); } + + void _showLogoutConfirmation(BuildContext context, AppLocalizations l10n) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.logout), + content: Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + context.read().logout(); + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(l10n.logout), + ), + ], + ), + ); + } } class _LanguageDialog extends StatefulWidget { diff --git a/lib/features/tasks/data/datasources/task_remote_datasource.dart b/lib/features/tasks/data/datasources/task_remote_datasource.dart index f550081..e9fafae 100644 --- a/lib/features/tasks/data/datasources/task_remote_datasource.dart +++ b/lib/features/tasks/data/datasources/task_remote_datasource.dart @@ -21,13 +21,15 @@ class TaskRemoteDataSourceImpl implements TaskRemoteDataSource { final AppLogger logger; final http.Client _client; - // Use 10.0.2.2 for Android emulator, localhost for desktop/web - static const String _baseUrl = 'http://localhost:8000'; + static const String _baseUrl = String.fromEnvironment( + 'API_URL', + defaultValue: 'http://localhost:8000', + ); TaskRemoteDataSourceImpl({ required this.logger, - http.Client? client, - }) : _client = client ?? http.Client(); + required http.Client client, + }) : _client = client; Map get _headers => { 'Content-Type': 'application/json', diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 08c492c..24ed9dd 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -34,5 +34,23 @@ "lightMode": "Hell", "darkModeOption": "Dunkel", "about": "Über", - "version": "Version" + "version": "Version", + "login": "Anmelden", + "register": "Registrieren", + "logout": "Abmelden", + "email": "E-Mail", + "emailRequired": "E-Mail ist erforderlich", + "emailInvalid": "Bitte geben Sie eine gültige E-Mail ein", + "password": "Passwort", + "passwordRequired": "Passwort ist erforderlich", + "passwordTooShort": "Passwort muss mindestens 8 Zeichen haben", + "name": "Name", + "nameRequired": "Name ist erforderlich", + "confirmPassword": "Passwort bestätigen", + "passwordsDoNotMatch": "Passwörter stimmen nicht überein", + "noAccountYet": "Noch kein Konto?", + "alreadyHaveAccount": "Bereits ein Konto?", + "loginSuccess": "Erfolgreich angemeldet", + "registerSuccess": "Konto erstellt. Bitte melden Sie sich an.", + "welcomeBack": "Willkommen zurück" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c8c3314..c569dfd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -34,5 +34,23 @@ "lightMode": "Light", "darkModeOption": "Dark", "about": "About", - "version": "Version" + "version": "Version", + "login": "Login", + "register": "Register", + "logout": "Logout", + "email": "Email", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email", + "password": "Password", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 8 characters", + "name": "Name", + "nameRequired": "Name is required", + "confirmPassword": "Confirm Password", + "passwordsDoNotMatch": "Passwords do not match", + "noAccountYet": "Don't have an account?", + "alreadyHaveAccount": "Already have an account?", + "loginSuccess": "Successfully logged in", + "registerSuccess": "Account created. Please log in.", + "welcomeBack": "Welcome back" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7091d39..2714a72 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -307,6 +307,114 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Version'** String get version; + + /// No description provided for @login. + /// + /// In en, this message translates to: + /// **'Login'** + String get login; + + /// No description provided for @register. + /// + /// In en, this message translates to: + /// **'Register'** + String get register; + + /// No description provided for @logout. + /// + /// In en, this message translates to: + /// **'Logout'** + String get logout; + + /// No description provided for @email. + /// + /// In en, this message translates to: + /// **'Email'** + String get email; + + /// No description provided for @emailRequired. + /// + /// In en, this message translates to: + /// **'Email is required'** + String get emailRequired; + + /// No description provided for @emailInvalid. + /// + /// In en, this message translates to: + /// **'Please enter a valid email'** + String get emailInvalid; + + /// No description provided for @password. + /// + /// In en, this message translates to: + /// **'Password'** + String get password; + + /// No description provided for @passwordRequired. + /// + /// In en, this message translates to: + /// **'Password is required'** + String get passwordRequired; + + /// No description provided for @passwordTooShort. + /// + /// In en, this message translates to: + /// **'Password must be at least 8 characters'** + String get passwordTooShort; + + /// No description provided for @name. + /// + /// In en, this message translates to: + /// **'Name'** + String get name; + + /// No description provided for @nameRequired. + /// + /// In en, this message translates to: + /// **'Name is required'** + String get nameRequired; + + /// No description provided for @confirmPassword. + /// + /// In en, this message translates to: + /// **'Confirm Password'** + String get confirmPassword; + + /// No description provided for @passwordsDoNotMatch. + /// + /// In en, this message translates to: + /// **'Passwords do not match'** + String get passwordsDoNotMatch; + + /// No description provided for @noAccountYet. + /// + /// In en, this message translates to: + /// **'Don\'t have an account?'** + String get noAccountYet; + + /// No description provided for @alreadyHaveAccount. + /// + /// In en, this message translates to: + /// **'Already have an account?'** + String get alreadyHaveAccount; + + /// No description provided for @loginSuccess. + /// + /// In en, this message translates to: + /// **'Successfully logged in'** + String get loginSuccess; + + /// No description provided for @registerSuccess. + /// + /// In en, this message translates to: + /// **'Account created. Please log in.'** + String get registerSuccess; + + /// No description provided for @welcomeBack. + /// + /// In en, this message translates to: + /// **'Welcome back'** + String get welcomeBack; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7c85570..9472aec 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -112,4 +112,58 @@ class AppLocalizationsDe extends AppLocalizations { @override String get version => 'Version'; + + @override + String get login => 'Anmelden'; + + @override + String get register => 'Registrieren'; + + @override + String get logout => 'Abmelden'; + + @override + String get email => 'E-Mail'; + + @override + String get emailRequired => 'E-Mail ist erforderlich'; + + @override + String get emailInvalid => 'Bitte geben Sie eine gültige E-Mail ein'; + + @override + String get password => 'Passwort'; + + @override + String get passwordRequired => 'Passwort ist erforderlich'; + + @override + String get passwordTooShort => 'Passwort muss mindestens 8 Zeichen haben'; + + @override + String get name => 'Name'; + + @override + String get nameRequired => 'Name ist erforderlich'; + + @override + String get confirmPassword => 'Passwort bestätigen'; + + @override + String get passwordsDoNotMatch => 'Passwörter stimmen nicht überein'; + + @override + String get noAccountYet => 'Noch kein Konto?'; + + @override + String get alreadyHaveAccount => 'Bereits ein Konto?'; + + @override + String get loginSuccess => 'Erfolgreich angemeldet'; + + @override + String get registerSuccess => 'Konto erstellt. Bitte melden Sie sich an.'; + + @override + String get welcomeBack => 'Willkommen zurück'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 32cde00..1ba5310 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -112,4 +112,58 @@ class AppLocalizationsEn extends AppLocalizations { @override String get version => 'Version'; + + @override + String get login => 'Login'; + + @override + String get register => 'Register'; + + @override + String get logout => 'Logout'; + + @override + String get email => 'Email'; + + @override + String get emailRequired => 'Email is required'; + + @override + String get emailInvalid => 'Please enter a valid email'; + + @override + String get password => 'Password'; + + @override + String get passwordRequired => 'Password is required'; + + @override + String get passwordTooShort => 'Password must be at least 8 characters'; + + @override + String get name => 'Name'; + + @override + String get nameRequired => 'Name is required'; + + @override + String get confirmPassword => 'Confirm Password'; + + @override + String get passwordsDoNotMatch => 'Passwords do not match'; + + @override + String get noAccountYet => 'Don\'t have an account?'; + + @override + String get alreadyHaveAccount => 'Already have an account?'; + + @override + String get loginSuccess => 'Successfully logged in'; + + @override + String get registerSuccess => 'Account created. Please log in.'; + + @override + String get welcomeBack => 'Welcome back'; } diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index fdc2a7a..a08835b 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -1,48 +1,92 @@ import 'package:go_router/go_router.dart'; +import '../core/di/injection_container.dart'; +import '../features/auth/presentation/pages/login_page.dart'; +import '../features/auth/presentation/pages/register_page.dart'; +import '../features/auth/presentation/viewmodels/auth_viewmodel.dart'; import '../features/tasks/presentation/pages/daily_agenda_page.dart'; import '../features/tasks/presentation/pages/calendar_page.dart'; import '../features/tasks/presentation/pages/task_form_page.dart'; import '../features/settings/presentation/pages/settings_page.dart'; class AppRouter { - static final router = GoRouter( - initialLocation: '/', - routes: [ - GoRoute( - path: '/', - name: 'daily', - builder: (context, state) { - final dateStr = state.uri.queryParameters['date']; - return DailyAgendaPage(initialDate: dateStr); - }, - ), - GoRoute( - path: '/calendar', - name: 'calendar', - builder: (context, state) => const CalendarPage(), - ), - GoRoute( - path: '/task/new', - name: 'task-new', - builder: (context, state) { - final dateStr = state.uri.queryParameters['date']; - return TaskFormPage(initialDate: dateStr); - }, - ), - GoRoute( - path: '/task/:id/edit', - name: 'task-edit', - builder: (context, state) { - final taskId = state.pathParameters['id']!; - return TaskFormPage(taskId: taskId); - }, - ), - GoRoute( - path: '/settings', - name: 'settings', - builder: (context, state) => const SettingsPage(), - ), - ], - ); + static GoRouter? _router; + + static GoRouter get router { + _router ??= _createRouter(); + return _router!; + } + + static GoRouter _createRouter() { + return GoRouter( + initialLocation: '/', + redirect: (context, state) { + final authViewModel = getIt(); + final isAuthenticated = authViewModel.isAuthenticated; + final isAuthRoute = + state.matchedLocation == '/login' || state.matchedLocation == '/register'; + + if (authViewModel.state == AuthState.initial) { + return null; + } + + if (!isAuthenticated && !isAuthRoute) { + return '/login'; + } + + if (isAuthenticated && isAuthRoute) { + return '/'; + } + + return null; + }, + refreshListenable: getIt(), + routes: [ + GoRoute( + path: '/login', + name: 'login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: '/register', + name: 'register', + builder: (context, state) => const RegisterPage(), + ), + GoRoute( + path: '/', + name: 'daily', + builder: (context, state) { + final dateStr = state.uri.queryParameters['date']; + return DailyAgendaPage(initialDate: dateStr); + }, + ), + GoRoute( + path: '/calendar', + name: 'calendar', + builder: (context, state) => const CalendarPage(), + ), + GoRoute( + path: '/task/new', + name: 'task-new', + builder: (context, state) { + final dateStr = state.uri.queryParameters['date']; + return TaskFormPage(initialDate: dateStr); + }, + ), + GoRoute( + path: '/task/:id/edit', + name: 'task-edit', + builder: (context, state) { + final taskId = state.pathParameters['id']!; + return TaskFormPage(taskId: taskId); + }, + ), + GoRoute( + path: '/settings', + name: 'settings', + builder: (context, state) => const SettingsPage(), + ), + ], + ); + } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..d0e7f79 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..b29e9ba 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..e6a54de 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import flutter_secure_storage_macos import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index badec3e..c55f835 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: @@ -203,6 +211,54 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -245,6 +301,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" http: dependency: "direct main" description: @@ -285,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -381,6 +453,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.6.3" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -389,6 +469,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e" + url: "https://pub.dev" + source: hosted + version: "9.2.5" package_config: dependency: transitive description: @@ -405,6 +493,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -682,6 +794,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -699,5 +819,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 4677b91..685dffd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: # Local Storage shared_preferences: ^2.3.5 + flutter_secure_storage: ^9.2.4 # Utilities intl: ^0.20.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..0c50753 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..4fc759c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST