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 '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<AuthViewModel>()),
|
||||
ChangeNotifierProvider(create: (_) => getIt<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: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<void> init() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
getIt.registerSingleton<SharedPreferences>(sharedPreferences);
|
||||
|
||||
const flutterSecureStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
getIt.registerSingleton<FlutterSecureStorage>(flutterSecureStorage);
|
||||
|
||||
// Logger
|
||||
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>(
|
||||
() => TaskRemoteDataSourceImpl(logger: getIt()),
|
||||
() => TaskRemoteDataSourceImpl(
|
||||
logger: getIt(),
|
||||
client: getIt<http.Client>(),
|
||||
),
|
||||
);
|
||||
getIt.registerLazySingleton<SettingsLocalDataSource>(
|
||||
() => SettingsLocalDataSourceImpl(sharedPreferences: getIt()),
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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});
|
||||
}
|
||||
|
||||
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: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<AuthViewModel>().logout();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(l10n.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LanguageDialog extends StatefulWidget {
|
||||
|
||||
@ -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<String, String> get _headers => {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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<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: [
|
||||
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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
|
||||
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
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
124
pubspec.lock
124
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"
|
||||
|
||||
@ -26,6 +26,7 @@ dependencies:
|
||||
|
||||
# Local Storage
|
||||
shared_preferences: ^2.3.5
|
||||
flutter_secure_storage: ^9.2.4
|
||||
|
||||
# Utilities
|
||||
intl: ^0.20.2
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user