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)
This commit is contained in:
parent
911f192c38
commit
d8164be49a
@ -4,6 +4,7 @@ import 'package:agenda_tasks/l10n/app_localizations.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'core/di/injection_container.dart';
|
import 'core/di/injection_container.dart';
|
||||||
|
import 'features/auth/presentation/viewmodels/auth_viewmodel.dart';
|
||||||
import 'features/settings/presentation/viewmodels/settings_viewmodel.dart';
|
import 'features/settings/presentation/viewmodels/settings_viewmodel.dart';
|
||||||
import 'routing/app_router.dart';
|
import 'routing/app_router.dart';
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ class AgendaApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: getIt<AuthViewModel>()),
|
||||||
ChangeNotifierProvider(create: (_) => getIt<SettingsViewModel>()),
|
ChangeNotifierProvider(create: (_) => getIt<SettingsViewModel>()),
|
||||||
],
|
],
|
||||||
child: Consumer<SettingsViewModel>(
|
child: Consumer<SettingsViewModel>(
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
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/settings/presentation/viewmodels/settings_viewmodel.dart';
|
||||||
import '../../features/tasks/presentation/viewmodels/daily_tasks_viewmodel.dart';
|
import '../../features/tasks/presentation/viewmodels/daily_tasks_viewmodel.dart';
|
||||||
import '../../features/tasks/presentation/viewmodels/task_form_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/tasks/data/datasources/task_remote_datasource.dart';
|
||||||
import '../../features/settings/data/settings_local_datasource.dart';
|
import '../../features/settings/data/settings_local_datasource.dart';
|
||||||
import '../logging/app_logger.dart';
|
import '../logging/app_logger.dart';
|
||||||
|
import '../network/authenticated_client.dart';
|
||||||
|
|
||||||
final getIt = GetIt.instance;
|
final getIt = GetIt.instance;
|
||||||
|
|
||||||
@ -17,12 +25,51 @@ Future<void> init() async {
|
|||||||
final sharedPreferences = await SharedPreferences.getInstance();
|
final sharedPreferences = await SharedPreferences.getInstance();
|
||||||
getIt.registerSingleton<SharedPreferences>(sharedPreferences);
|
getIt.registerSingleton<SharedPreferences>(sharedPreferences);
|
||||||
|
|
||||||
|
const flutterSecureStorage = FlutterSecureStorage(
|
||||||
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
|
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||||
|
);
|
||||||
|
getIt.registerSingleton<FlutterSecureStorage>(flutterSecureStorage);
|
||||||
|
|
||||||
// Logger
|
// Logger
|
||||||
getIt.registerSingleton<AppLogger>(AppLogger());
|
getIt.registerSingleton<AppLogger>(AppLogger());
|
||||||
|
|
||||||
// Data sources
|
// Auth Data sources
|
||||||
|
getIt.registerLazySingleton<AuthLocalDataSource>(
|
||||||
|
() => AuthLocalDataSourceImpl(storage: getIt()),
|
||||||
|
);
|
||||||
|
getIt.registerLazySingleton<AuthRemoteDataSource>(
|
||||||
|
() => AuthRemoteDataSourceImpl(logger: getIt()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth Repository
|
||||||
|
getIt.registerLazySingleton<AuthRepository>(
|
||||||
|
() => AuthRepositoryImpl(
|
||||||
|
remoteDataSource: getIt(),
|
||||||
|
localDataSource: getIt(),
|
||||||
|
logger: getIt(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth ViewModel (singleton for global state)
|
||||||
|
getIt.registerSingleton<AuthViewModel>(
|
||||||
|
AuthViewModel(repository: getIt(), logger: getIt()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authenticated HTTP Client
|
||||||
|
getIt.registerLazySingleton<http.Client>(
|
||||||
|
() => AuthenticatedClient(
|
||||||
|
localDataSource: getIt(),
|
||||||
|
authRepository: getIt(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Task Data sources
|
||||||
getIt.registerLazySingleton<TaskRemoteDataSource>(
|
getIt.registerLazySingleton<TaskRemoteDataSource>(
|
||||||
() => TaskRemoteDataSourceImpl(logger: getIt()),
|
() => TaskRemoteDataSourceImpl(
|
||||||
|
logger: getIt(),
|
||||||
|
client: getIt<http.Client>(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
getIt.registerLazySingleton<SettingsLocalDataSource>(
|
getIt.registerLazySingleton<SettingsLocalDataSource>(
|
||||||
() => SettingsLocalDataSourceImpl(sharedPreferences: getIt()),
|
() => SettingsLocalDataSourceImpl(sharedPreferences: getIt()),
|
||||||
|
|||||||
@ -35,3 +35,21 @@ class NotFoundException implements Exception {
|
|||||||
@override
|
@override
|
||||||
String toString() => 'NotFoundException: $message';
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@ -27,3 +27,11 @@ class NotFoundFailure extends Failure {
|
|||||||
class UnexpectedFailure extends Failure {
|
class UnexpectedFailure extends Failure {
|
||||||
const UnexpectedFailure({super.message = 'An unexpected error occurred', super.code});
|
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});
|
||||||
|
}
|
||||||
|
|||||||
68
lib/core/network/authenticated_client.dart
Normal file
68
lib/core/network/authenticated_client.dart
Normal file
@ -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<http.StreamedResponse> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
lib/features/auth/data/datasources/auth_local_datasource.dart
Normal file
103
lib/features/auth/data/datasources/auth_local_datasource.dart
Normal file
@ -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<void> saveTokens(TokenModel tokens);
|
||||||
|
Future<TokenModel?> getTokens();
|
||||||
|
Future<void> deleteTokens();
|
||||||
|
Future<void> saveUser(UserModel user);
|
||||||
|
Future<UserModel?> getUser();
|
||||||
|
Future<void> deleteUser();
|
||||||
|
Future<void> clearAll();
|
||||||
|
Stream<bool> get authStateChanges;
|
||||||
|
void notifyAuthStateChanged(bool isAuthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthLocalDataSourceImpl implements AuthLocalDataSource {
|
||||||
|
final FlutterSecureStorage _storage;
|
||||||
|
final _authStateController = StreamController<bool>.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<void> 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<TokenModel?> 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<void> deleteTokens() async {
|
||||||
|
await Future.wait([
|
||||||
|
_storage.delete(key: _accessTokenKey),
|
||||||
|
_storage.delete(key: _refreshTokenKey),
|
||||||
|
_storage.delete(key: _tokenTypeKey),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveUser(UserModel user) async {
|
||||||
|
await _storage.write(key: _userKey, value: json.encode(user.toJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserModel?> getUser() async {
|
||||||
|
final userJson = await _storage.read(key: _userKey);
|
||||||
|
if (userJson == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return UserModel.fromJson(json.decode(userJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteUser() async {
|
||||||
|
await _storage.delete(key: _userKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearAll() async {
|
||||||
|
await Future.wait([
|
||||||
|
deleteTokens(),
|
||||||
|
deleteUser(),
|
||||||
|
]);
|
||||||
|
notifyAuthStateChanged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> get authStateChanges => _authStateController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void notifyAuthStateChanged(bool isAuthenticated) {
|
||||||
|
_authStateController.add(isAuthenticated);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
lib/features/auth/data/datasources/auth_remote_datasource.dart
Normal file
179
lib/features/auth/data/datasources/auth_remote_datasource.dart
Normal file
@ -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<UserModel> register({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String name,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<TokenModel> login({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<UserModel> getCurrentUser(String accessToken);
|
||||||
|
|
||||||
|
Future<TokenModel> 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<String, String> get _headers => {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, String> _authHeaders(String token) => {
|
||||||
|
..._headers,
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserModel> 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<TokenModel> 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<UserModel> 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<TokenModel> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib/features/auth/data/models/token_model.dart
Normal file
27
lib/features/auth/data/models/token_model.dart
Normal file
@ -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<String, dynamic> json) {
|
||||||
|
return TokenModel(
|
||||||
|
accessToken: json['access_token'] as String,
|
||||||
|
refreshToken: json['refresh_token'] as String,
|
||||||
|
tokenType: json['token_type'] as String? ?? 'bearer',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'access_token': accessToken,
|
||||||
|
'refresh_token': refreshToken,
|
||||||
|
'token_type': tokenType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
lib/features/auth/data/models/user_model.dart
Normal file
39
lib/features/auth/data/models/user_model.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
153
lib/features/auth/data/repositories/auth_repository_impl.dart
Normal file
153
lib/features/auth/data/repositories/auth_repository_impl.dart
Normal file
@ -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<Result<UserEntity>> 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<Result<UserEntity>> 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<Result<void>> logout() async {
|
||||||
|
try {
|
||||||
|
await localDataSource.clearAll();
|
||||||
|
return const Success(null);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error during logout: $e');
|
||||||
|
return const Error(UnexpectedFailure());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<UserEntity>> 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<Result<void>> 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<bool> isAuthenticated() async {
|
||||||
|
final tokens = await localDataSource.getTokens();
|
||||||
|
return tokens != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<bool> get authStateChanges => localDataSource.authStateChanges;
|
||||||
|
}
|
||||||
36
lib/features/auth/domain/entities/user_entity.dart
Normal file
36
lib/features/auth/domain/entities/user_entity.dart
Normal file
@ -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;
|
||||||
|
}
|
||||||
25
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
25
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import '../../../../core/errors/result.dart';
|
||||||
|
import '../entities/user_entity.dart';
|
||||||
|
|
||||||
|
abstract class AuthRepository {
|
||||||
|
Future<Result<UserEntity>> register({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String name,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Result<UserEntity>> login({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Result<void>> logout();
|
||||||
|
|
||||||
|
Future<Result<UserEntity>> getCurrentUser();
|
||||||
|
|
||||||
|
Future<Result<void>> refreshToken();
|
||||||
|
|
||||||
|
Future<bool> isAuthenticated();
|
||||||
|
|
||||||
|
Stream<bool> get authStateChanges;
|
||||||
|
}
|
||||||
190
lib/features/auth/presentation/pages/login_page.dart
Normal file
190
lib/features/auth/presentation/pages/login_page.dart
Normal file
@ -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<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends State<LoginPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _login() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final viewModel = context.read<AuthViewModel>();
|
||||||
|
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<AuthViewModel>(
|
||||||
|
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<AuthViewModel>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
lib/features/auth/presentation/pages/register_page.dart
Normal file
244
lib/features/auth/presentation/pages/register_page.dart
Normal file
@ -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<RegisterPage> createState() => _RegisterPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterPageState extends State<RegisterPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _register() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final viewModel = context.read<AuthViewModel>();
|
||||||
|
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<AuthViewModel>(
|
||||||
|
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<AuthViewModel>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
lib/features/auth/presentation/viewmodels/auth_viewmodel.dart
Normal file
160
lib/features/auth/presentation/viewmodels/auth_viewmodel.dart
Normal file
@ -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<bool>? _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<void> _init() async {
|
||||||
|
_authStateSubscription = repository.authStateChanges.listen((isAuth) {
|
||||||
|
if (!isAuth && _state == AuthState.authenticated) {
|
||||||
|
_state = AuthState.unauthenticated;
|
||||||
|
_user = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await checkAuthStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<bool> 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<bool> 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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import 'package:agenda_tasks/l10n/app_localizations.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../auth/presentation/viewmodels/auth_viewmodel.dart';
|
||||||
import '../viewmodels/settings_viewmodel.dart';
|
import '../viewmodels/settings_viewmodel.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatelessWidget {
|
class SettingsPage extends StatelessWidget {
|
||||||
@ -47,6 +48,24 @@ class SettingsPage extends StatelessWidget {
|
|||||||
title: Text(l10n.version),
|
title: Text(l10n.version),
|
||||||
subtitle: const Text('1.0.0'),
|
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<AuthViewModel>().logout();
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: Text(l10n.logout),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LanguageDialog extends StatefulWidget {
|
class _LanguageDialog extends StatefulWidget {
|
||||||
|
|||||||
@ -21,13 +21,15 @@ class TaskRemoteDataSourceImpl implements TaskRemoteDataSource {
|
|||||||
final AppLogger logger;
|
final AppLogger logger;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
|
||||||
// Use 10.0.2.2 for Android emulator, localhost for desktop/web
|
static const String _baseUrl = String.fromEnvironment(
|
||||||
static const String _baseUrl = 'http://localhost:8000';
|
'API_URL',
|
||||||
|
defaultValue: 'http://localhost:8000',
|
||||||
|
);
|
||||||
|
|
||||||
TaskRemoteDataSourceImpl({
|
TaskRemoteDataSourceImpl({
|
||||||
required this.logger,
|
required this.logger,
|
||||||
http.Client? client,
|
required http.Client client,
|
||||||
}) : _client = client ?? http.Client();
|
}) : _client = client;
|
||||||
|
|
||||||
Map<String, String> get _headers => {
|
Map<String, String> get _headers => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@ -34,5 +34,23 @@
|
|||||||
"lightMode": "Hell",
|
"lightMode": "Hell",
|
||||||
"darkModeOption": "Dunkel",
|
"darkModeOption": "Dunkel",
|
||||||
"about": "Über",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,5 +34,23 @@
|
|||||||
"lightMode": "Light",
|
"lightMode": "Light",
|
||||||
"darkModeOption": "Dark",
|
"darkModeOption": "Dark",
|
||||||
"about": "About",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -307,6 +307,114 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Version'**
|
/// **'Version'**
|
||||||
String get 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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@ -112,4 +112,58 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get version => 'Version';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -112,4 +112,58 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get version => 'Version';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,57 @@
|
|||||||
import 'package:go_router/go_router.dart';
|
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/daily_agenda_page.dart';
|
||||||
import '../features/tasks/presentation/pages/calendar_page.dart';
|
import '../features/tasks/presentation/pages/calendar_page.dart';
|
||||||
import '../features/tasks/presentation/pages/task_form_page.dart';
|
import '../features/tasks/presentation/pages/task_form_page.dart';
|
||||||
import '../features/settings/presentation/pages/settings_page.dart';
|
import '../features/settings/presentation/pages/settings_page.dart';
|
||||||
|
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
static final router = GoRouter(
|
static GoRouter? _router;
|
||||||
|
|
||||||
|
static GoRouter get router {
|
||||||
|
_router ??= _createRouter();
|
||||||
|
return _router!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static GoRouter _createRouter() {
|
||||||
|
return GoRouter(
|
||||||
initialLocation: '/',
|
initialLocation: '/',
|
||||||
|
redirect: (context, state) {
|
||||||
|
final authViewModel = getIt<AuthViewModel>();
|
||||||
|
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<AuthViewModel>(),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
builder: (context, state) => const LoginPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/register',
|
||||||
|
name: 'register',
|
||||||
|
builder: (context, state) => const RegisterPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'daily',
|
name: 'daily',
|
||||||
@ -46,3 +89,4 @@ class AppRouter {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@ -5,8 +5,10 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import flutter_secure_storage_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
124
pubspec.lock
124
pubspec.lock
@ -113,6 +113,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
code_builder:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -203,6 +211,54 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -245,6 +301,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -285,6 +349,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.7"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -381,6 +453,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.6.3"
|
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:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -389,6 +469,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -405,6 +493,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -682,6 +794,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -699,5 +819,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@ -26,6 +26,7 @@ dependencies:
|
|||||||
|
|
||||||
# Local Storage
|
# Local Storage
|
||||||
shared_preferences: ^2.3.5
|
shared_preferences: ^2.3.5
|
||||||
|
flutter_secure_storage: ^9.2.4
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
|||||||
@ -6,6 +6,9 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user