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:
m3mo 2026-02-02 22:58:07 +01:00
parent 911f192c38
commit d8164be49a
30 changed files with 1823 additions and 49 deletions

View File

@ -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>(

View File

@ -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()),

View File

@ -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';
}

View File

@ -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});
}

View 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();
}
}

View 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);
}
}

View 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();
}
}
}

View 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,
};
}
}

View 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,
);
}
}

View 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;
}

View 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;
}

View 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;
}

View 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),
),
],
),
],
),
),
),
),
),
),
);
}
}

View 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),
),
],
),
],
),
),
),
),
),
),
);
}
}

View 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();
}
}

View File

@ -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 {

View File

@ -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',

View File

@ -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"
} }

View File

@ -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"
} }

View File

@ -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

View File

@ -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';
} }

View File

@ -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';
} }

View File

@ -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 {
], ],
); );
} }
}

View File

@ -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);
} }

View File

@ -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

View File

@ -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"))
} }

View File

@ -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"

View File

@ -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

View File

@ -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"));
} }

View File

@ -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