feat: WebSocket temps réel + Finance Workflow + corrections
- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics) * Backend: KafkaEventProducer, KafkaEventConsumer * Mobile: WebSocketService (reconnection, heartbeat, typed events) * DashboardBloc: Auto-refresh depuis WebSocket events - Finance Workflow: approbations + budgets (backend + mobile) * Backend: entities, services, resources, migrations Flyway V6 * Mobile: features finance_workflow complète avec BLoC - Corrections DI: interfaces IRepository partout * IProfileRepository, IOrganizationRepository, IMembreRepository * GetIt configuré avec @injectable - Spec-Kit: constitution + templates mis à jour * .specify/memory/constitution.md enrichie * Templates agent, plan, spec, tasks, checklist - Nettoyage: fichiers temporaires supprimés Signed-off-by: lions dev Team
This commit is contained in:
@@ -1,468 +1,139 @@
|
||||
/// BLoC d'authentification Keycloak adaptatif avec gestion des rôles
|
||||
/// Support Keycloak avec contextes multi-organisations et états sophistiqués
|
||||
library auth_bloc;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../data/models/user.dart';
|
||||
import '../../data/models/user_role.dart';
|
||||
import '../../data/datasources/permission_engine.dart';
|
||||
import '../../data/datasources/keycloak_auth_service.dart';
|
||||
import '../../data/datasources/dashboard_cache_manager.dart';
|
||||
import '../../data/datasources/permission_engine.dart';
|
||||
import '../../../../core/storage/dashboard_cache_manager.dart';
|
||||
|
||||
// === ÉVÉNEMENTS ===
|
||||
|
||||
/// Événements d'authentification
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Événement de connexion Keycloak
|
||||
class AuthLoginRequested extends AuthEvent {
|
||||
const AuthLoginRequested();
|
||||
}
|
||||
|
||||
/// Événement de déconnexion
|
||||
class AuthLogoutRequested extends AuthEvent {
|
||||
const AuthLogoutRequested();
|
||||
}
|
||||
|
||||
/// Événement de changement de contexte organisationnel
|
||||
class AuthOrganizationContextChanged extends AuthEvent {
|
||||
final String organizationId;
|
||||
|
||||
const AuthOrganizationContextChanged(this.organizationId);
|
||||
|
||||
final String email;
|
||||
final String password;
|
||||
const AuthLoginRequested(this.email, this.password);
|
||||
@override
|
||||
List<Object?> get props => [organizationId];
|
||||
List<Object?> get props => [email, password];
|
||||
}
|
||||
|
||||
/// Événement de rafraîchissement du token
|
||||
class AuthTokenRefreshRequested extends AuthEvent {
|
||||
const AuthTokenRefreshRequested();
|
||||
}
|
||||
|
||||
/// Événement de vérification de l'état d'authentification
|
||||
class AuthStatusChecked extends AuthEvent {
|
||||
const AuthStatusChecked();
|
||||
}
|
||||
|
||||
/// Événement de mise à jour du profil utilisateur
|
||||
class AuthUserProfileUpdated extends AuthEvent {
|
||||
final User updatedUser;
|
||||
|
||||
const AuthUserProfileUpdated(this.updatedUser);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [updatedUser];
|
||||
}
|
||||
|
||||
/// Événement de callback WebView
|
||||
class AuthWebViewCallback extends AuthEvent {
|
||||
final String callbackUrl;
|
||||
final User? user;
|
||||
|
||||
const AuthWebViewCallback(this.callbackUrl, {this.user});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [callbackUrl, user];
|
||||
}
|
||||
class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
|
||||
class AuthStatusChecked extends AuthEvent { const AuthStatusChecked(); }
|
||||
class AuthTokenRefreshRequested extends AuthEvent { const AuthTokenRefreshRequested(); }
|
||||
|
||||
// === ÉTATS ===
|
||||
|
||||
/// États d'authentification
|
||||
abstract class AuthState extends Equatable {
|
||||
const AuthState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class AuthInitial extends AuthState {
|
||||
const AuthInitial();
|
||||
}
|
||||
class AuthInitial extends AuthState {}
|
||||
class AuthLoading extends AuthState {}
|
||||
class AuthUnauthenticated extends AuthState {}
|
||||
|
||||
/// État de chargement
|
||||
class AuthLoading extends AuthState {
|
||||
const AuthLoading();
|
||||
}
|
||||
|
||||
/// État authentifié avec contexte riche
|
||||
class AuthAuthenticated extends AuthState {
|
||||
final User user;
|
||||
final String? currentOrganizationId;
|
||||
final UserRole effectiveRole;
|
||||
final List<String> effectivePermissions;
|
||||
final DateTime authenticatedAt;
|
||||
final String? accessToken;
|
||||
|
||||
final String accessToken;
|
||||
|
||||
const AuthAuthenticated({
|
||||
required this.user,
|
||||
this.currentOrganizationId,
|
||||
required this.effectiveRole,
|
||||
required this.effectivePermissions,
|
||||
required this.authenticatedAt,
|
||||
this.accessToken,
|
||||
required this.accessToken,
|
||||
});
|
||||
|
||||
/// Vérifie si l'utilisateur a une permission
|
||||
bool hasPermission(String permission) {
|
||||
return effectivePermissions.contains(permission);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur peut effectuer une action
|
||||
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
|
||||
final permission = '$domain.$action.$scope';
|
||||
return hasPermission(permission);
|
||||
}
|
||||
|
||||
/// Obtient le contexte organisationnel actuel
|
||||
UserOrganizationContext? get currentOrganizationContext {
|
||||
if (currentOrganizationId == null) return null;
|
||||
return user.getOrganizationContext(currentOrganizationId!);
|
||||
}
|
||||
|
||||
/// Crée une copie avec des modifications
|
||||
AuthAuthenticated copyWith({
|
||||
User? user,
|
||||
String? currentOrganizationId,
|
||||
UserRole? effectiveRole,
|
||||
List<String>? effectivePermissions,
|
||||
DateTime? authenticatedAt,
|
||||
String? accessToken,
|
||||
}) {
|
||||
return AuthAuthenticated(
|
||||
user: user ?? this.user,
|
||||
currentOrganizationId: currentOrganizationId ?? this.currentOrganizationId,
|
||||
effectiveRole: effectiveRole ?? this.effectiveRole,
|
||||
effectivePermissions: effectivePermissions ?? this.effectivePermissions,
|
||||
authenticatedAt: authenticatedAt ?? this.authenticatedAt,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
user,
|
||||
currentOrganizationId,
|
||||
effectiveRole,
|
||||
effectivePermissions,
|
||||
authenticatedAt,
|
||||
accessToken,
|
||||
];
|
||||
List<Object?> get props => [user, effectiveRole, effectivePermissions, accessToken];
|
||||
}
|
||||
|
||||
/// État non authentifié
|
||||
class AuthUnauthenticated extends AuthState {
|
||||
final String? message;
|
||||
|
||||
const AuthUnauthenticated({this.message});
|
||||
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
const AuthError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
final String? errorCode;
|
||||
|
||||
const AuthError({
|
||||
required this.message,
|
||||
this.errorCode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, errorCode];
|
||||
}
|
||||
|
||||
/// État indiquant qu'une WebView d'authentification est requise
|
||||
class AuthWebViewRequired extends AuthState {
|
||||
final String authUrl;
|
||||
final String state;
|
||||
final String codeVerifier;
|
||||
|
||||
const AuthWebViewRequired({
|
||||
required this.authUrl,
|
||||
required this.state,
|
||||
required this.codeVerifier,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [authUrl, state, codeVerifier];
|
||||
}
|
||||
|
||||
// === BLOC ===
|
||||
|
||||
/// BLoC d'authentification adaptatif
|
||||
@lazySingleton
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc() : super(const AuthInitial()) {
|
||||
final KeycloakAuthService _authService;
|
||||
|
||||
AuthBloc(this._authService) : super(AuthInitial()) {
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
on<AuthOrganizationContextChanged>(_onOrganizationContextChanged);
|
||||
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
|
||||
on<AuthStatusChecked>(_onStatusChecked);
|
||||
on<AuthUserProfileUpdated>(_onUserProfileUpdated);
|
||||
on<AuthWebViewCallback>(_onWebViewCallback);
|
||||
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
|
||||
}
|
||||
|
||||
/// Gère la demande de connexion Keycloak via WebView
|
||||
///
|
||||
/// Cette méthode prépare l'authentification WebView et émet un état spécial
|
||||
/// pour indiquer qu'une WebView doit être ouverte
|
||||
Future<void> _onLoginRequested(
|
||||
AuthLoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
debugPrint('🔐 Préparation authentification Keycloak WebView...');
|
||||
|
||||
// Préparer l'authentification WebView
|
||||
final Map<String, String> authParams = await KeycloakAuthService.prepareWebViewAuthentication();
|
||||
|
||||
debugPrint('✅ Authentification WebView préparée');
|
||||
|
||||
// Émettre un état spécial pour indiquer qu'une WebView doit être ouverte
|
||||
debugPrint('🚀 Émission de l\'état AuthWebViewRequired...');
|
||||
emit(AuthWebViewRequired(
|
||||
authUrl: authParams['url']!,
|
||||
state: authParams['state']!,
|
||||
codeVerifier: authParams['code_verifier']!,
|
||||
));
|
||||
debugPrint('✅ État AuthWebViewRequired émis');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur préparation authentification Keycloak: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de préparation: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite le callback WebView et finalise l'authentification
|
||||
Future<void> _onWebViewCallback(
|
||||
AuthWebViewCallback event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🔄 Traitement callback WebView...');
|
||||
|
||||
// Utiliser l'utilisateur fourni ou traiter le callback
|
||||
final User user;
|
||||
if (event.user != null) {
|
||||
debugPrint('👤 Utilisation des données utilisateur fournies: ${event.user!.fullName}');
|
||||
user = event.user!;
|
||||
final user = await _authService.login(event.email, event.password);
|
||||
if (user != null) {
|
||||
final permissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
final token = await _authService.getValidToken();
|
||||
await DashboardCacheManager.invalidateForRole(user.primaryRole);
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: permissions,
|
||||
accessToken: token ?? '',
|
||||
));
|
||||
} else {
|
||||
debugPrint('🔄 Traitement du callback URL: ${event.callbackUrl}');
|
||||
user = await KeycloakAuthService.handleWebViewCallback(event.callbackUrl);
|
||||
emit(const AuthError('Identifiants incorrects.'));
|
||||
}
|
||||
|
||||
debugPrint('👤 Utilisateur authentifié: ${user.fullName} (${user.primaryRole.displayName})');
|
||||
|
||||
// Calculer les permissions effectives
|
||||
debugPrint('🔐 Calcul des permissions effectives...');
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
debugPrint('✅ Permissions effectives calculées: ${effectivePermissions.length} permissions');
|
||||
|
||||
// Invalider le cache pour forcer le rechargement
|
||||
debugPrint('🧹 Invalidation du cache pour le rôle ${user.primaryRole.displayName}...');
|
||||
await DashboardCacheManager.invalidateForRole(user.primaryRole);
|
||||
debugPrint('✅ Cache invalidé');
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
currentOrganizationId: null, // À implémenter selon vos besoins
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
authenticatedAt: DateTime.now(),
|
||||
accessToken: '', // Token géré par KeycloakWebViewAuthService
|
||||
));
|
||||
|
||||
debugPrint('🎉 Authentification complète réussie - navigation vers dashboard');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur authentification: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de connexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère la demande de déconnexion Keycloak
|
||||
Future<void> _onLogoutRequested(
|
||||
AuthLogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🚪 Démarrage déconnexion Keycloak...');
|
||||
|
||||
// Déconnexion Keycloak
|
||||
final logoutSuccess = await KeycloakAuthService.logout();
|
||||
|
||||
if (!logoutSuccess) {
|
||||
debugPrint('⚠️ Déconnexion Keycloak partielle');
|
||||
}
|
||||
|
||||
// Nettoyer le cache local
|
||||
await DashboardCacheManager.clear();
|
||||
|
||||
// Invalider le cache des permissions
|
||||
if (state is AuthAuthenticated) {
|
||||
final authState = state as AuthAuthenticated;
|
||||
PermissionEngine.invalidateUserCache(authState.user.id);
|
||||
}
|
||||
|
||||
debugPrint('✅ Déconnexion complète réussie');
|
||||
emit(const AuthUnauthenticated(message: 'Déconnexion réussie'));
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur déconnexion: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de déconnexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le changement de contexte organisationnel
|
||||
Future<void> _onOrganizationContextChanged(
|
||||
AuthOrganizationContextChanged event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
// Recalculer le rôle effectif et les permissions
|
||||
final effectiveRole = currentState.user.getRoleInOrganization(event.organizationId);
|
||||
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
|
||||
currentState.user,
|
||||
organizationId: event.organizationId,
|
||||
);
|
||||
|
||||
// Invalider le cache pour le nouveau contexte
|
||||
PermissionEngine.invalidateUserCache(currentState.user.id);
|
||||
|
||||
emit(currentState.copyWith(
|
||||
currentOrganizationId: event.organizationId,
|
||||
effectiveRole: effectiveRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de changement de contexte: $e'));
|
||||
emit(AuthError('Erreur de connexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le rafraîchissement du token
|
||||
Future<void> _onTokenRefreshRequested(
|
||||
AuthTokenRefreshRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
|
||||
try {
|
||||
// Simulation du rafraîchissement (à remplacer par l'API réelle)
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
final newToken = 'refreshed_token_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
emit(currentState.copyWith(accessToken: newToken));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de rafraîchissement: $e'));
|
||||
}
|
||||
Future<void> _onLogoutRequested(AuthLogoutRequested event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
await _authService.logout();
|
||||
await DashboardCacheManager.clear();
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
|
||||
/// Vérifie l'état d'authentification Keycloak
|
||||
Future<void> _onStatusChecked(
|
||||
AuthStatusChecked event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
Future<void> _onStatusChecked(AuthStatusChecked event, Emitter<AuthState> emit) async {
|
||||
final tokenValid = await _authService.getValidToken();
|
||||
final isAuth = tokenValid != null;
|
||||
if (!isAuth) {
|
||||
emit(AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
final user = await _authService.getCurrentUser();
|
||||
if (user == null) {
|
||||
emit(AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
final permissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
final token = await _authService.getValidToken();
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: permissions,
|
||||
accessToken: token ?? '',
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔍 Vérification état authentification Keycloak...');
|
||||
|
||||
// Vérifier si l'utilisateur est authentifié avec Keycloak
|
||||
final bool isAuthenticated = await KeycloakAuthService.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
debugPrint('❌ Utilisateur non authentifié');
|
||||
emit(const AuthUnauthenticated());
|
||||
return;
|
||||
Future<void> _onTokenRefreshRequested(AuthTokenRefreshRequested event, Emitter<AuthState> emit) async {
|
||||
if (state is AuthAuthenticated) {
|
||||
final newToken = await _authService.refreshToken();
|
||||
final success = newToken != null;
|
||||
if (success) {
|
||||
add(AuthStatusChecked());
|
||||
} else {
|
||||
add(AuthLogoutRequested());
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
final User? user = await KeycloakAuthService.getCurrentUser();
|
||||
|
||||
if (user == null) {
|
||||
debugPrint('❌ Impossible de récupérer l\'utilisateur');
|
||||
emit(const AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculer les permissions effectives
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
|
||||
// Récupérer le token d'accès
|
||||
final String? accessToken = await KeycloakAuthService.getAccessToken();
|
||||
|
||||
debugPrint('✅ Utilisateur authentifié: ${user.fullName}');
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
currentOrganizationId: null, // À implémenter selon vos besoins
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
authenticatedAt: DateTime.now(),
|
||||
accessToken: accessToken ?? '',
|
||||
));
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur vérification authentification: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de vérification: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour le profil utilisateur
|
||||
Future<void> _onUserProfileUpdated(
|
||||
AuthUserProfileUpdated event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
|
||||
try {
|
||||
// Recalculer les permissions si nécessaire
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
|
||||
event.updatedUser,
|
||||
organizationId: currentState.currentOrganizationId,
|
||||
);
|
||||
|
||||
emit(currentState.copyWith(
|
||||
user: event.updatedUser,
|
||||
effectivePermissions: effectivePermissions,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de mise à jour: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,160 +1,68 @@
|
||||
/// Page de Connexion UnionFlow - Design System Unifié (Version Premium)
|
||||
/// Interface de connexion moderne orientée métier avec animations avancées
|
||||
/// Utilise la palette Bleu Roi (#4169E1) + Bleu Pétrole (#2C5F6F)
|
||||
library login_page;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import 'keycloak_webview_auth_page.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Page de connexion UnionFlow
|
||||
/// Présente l'application et permet l'authentification sécurisée
|
||||
import '../bloc/auth_bloc.dart';
|
||||
import '../../../../core/config/environment.dart';
|
||||
import '../../../../shared/widgets/core_text_field.dart';
|
||||
import '../../../../shared/widgets/dynamic_fab.dart';
|
||||
import '../../../../shared/design_system/tokens/app_typography.dart';
|
||||
import '../../../../shared/design_system/tokens/app_colors.dart';
|
||||
|
||||
/// UnionFlow Mobile - Écran de connexion (Mode DRY & Minimaliste)
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
const LoginPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage>
|
||||
with TickerProviderStateMixin {
|
||||
|
||||
late AnimationController _animationController;
|
||||
late AnimationController _backgroundController;
|
||||
late AnimationController _pulseController;
|
||||
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _backgroundAnimation;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
}
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_backgroundController.dispose();
|
||||
_pulseController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeAnimations() {
|
||||
// Animation principale d'entrée
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
vsync: this,
|
||||
Future<void> _openForgotPassword(BuildContext context) async {
|
||||
final url = Uri.parse(
|
||||
'${AppConfig.keycloakRealmUrl}/protocol/openid-connect/auth'
|
||||
'?client_id=unionflow-mobile'
|
||||
'&redirect_uri=${Uri.encodeComponent('http://localhost')}'
|
||||
'&response_type=code'
|
||||
'&scope=openid'
|
||||
'&kc_action=reset_credentials',
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.4),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
||||
));
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.6, curve: Curves.easeOutBack),
|
||||
));
|
||||
|
||||
// Animation de fond subtile
|
||||
_backgroundController = AnimationController(
|
||||
duration: const Duration(seconds: 8),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_backgroundAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _backgroundController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
// Animation de pulsation pour le logo
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(seconds: 3),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_pulseAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.08,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
try {
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Impossible d\'ouvrir la page de réinitialisation')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Erreur lors de l\'ouverture du lien')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre la page WebView d'authentification
|
||||
void _openWebViewAuth(BuildContext context, AuthWebViewRequired state) {
|
||||
debugPrint('🚀 Ouverture WebView avec URL: ${state.authUrl}');
|
||||
debugPrint('🔑 State: ${state.state}');
|
||||
debugPrint('🔐 Code verifier: ${state.codeVerifier.substring(0, 10)}...');
|
||||
void _onLogin() {
|
||||
final email = _emailController.text;
|
||||
final password = _passwordController.text;
|
||||
|
||||
debugPrint('📱 Tentative de navigation vers KeycloakWebViewAuthPage...');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => KeycloakWebViewAuthPage(
|
||||
onAuthSuccess: (user) {
|
||||
debugPrint('✅ Authentification réussie pour: ${user.fullName}');
|
||||
debugPrint('🔄 Notification du BLoC avec les données utilisateur...');
|
||||
|
||||
context.read<AuthBloc>().add(AuthWebViewCallback(
|
||||
'success',
|
||||
user: user,
|
||||
));
|
||||
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onAuthError: (error) {
|
||||
debugPrint('❌ Erreur d\'authentification: $error');
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur d\'authentification: $error'),
|
||||
backgroundColor: ColorTokens.error,
|
||||
duration: const Duration(seconds: 5),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
onAuthCancel: () {
|
||||
debugPrint('❌ Authentification annulée par l\'utilisateur');
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Authentification annulée'),
|
||||
backgroundColor: ColorTokens.warning,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
debugPrint('✅ Navigation vers KeycloakWebViewAuthPage lancée');
|
||||
if (email.isNotEmpty && password.isNotEmpty) {
|
||||
context.read<AuthBloc>().add(AuthLoginRequested(email, password));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -162,577 +70,100 @@ class _LoginPageState extends State<LoginPage>
|
||||
return Scaffold(
|
||||
body: BlocConsumer<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
debugPrint('🔄 État BLoC reçu: ${state.runtimeType}');
|
||||
|
||||
if (state is AuthAuthenticated) {
|
||||
debugPrint('✅ Utilisateur authentifié, navigation vers dashboard');
|
||||
Navigator.of(context).pushReplacementNamed('/dashboard');
|
||||
// Navigator 1.0 : Le BlocBuilder dans AppRouter gérera la transition vers MainNavigationLayout
|
||||
} else if (state is AuthError) {
|
||||
debugPrint('❌ Erreur d\'authentification: ${state.message}');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: ColorTokens.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(state.message, style: AppTypography.bodyTextSmall),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
} else if (state is AuthWebViewRequired) {
|
||||
debugPrint('🚀 État AuthWebViewRequired reçu, ouverture WebView...');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openWebViewAuth(context, state);
|
||||
});
|
||||
} else if (state is AuthLoading) {
|
||||
debugPrint('⏳ État de chargement...');
|
||||
} else {
|
||||
debugPrint('ℹ️ État non géré: ${state.runtimeType}');
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is AuthWebViewRequired) {
|
||||
debugPrint('🔄 Builder détecte AuthWebViewRequired, ouverture WebView...');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openWebViewAuth(context, state);
|
||||
});
|
||||
}
|
||||
final isLoading = state is AuthLoading;
|
||||
|
||||
return _buildLoginContent(context, state);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo minimaliste (Texte seul)
|
||||
Center(
|
||||
child: Text(
|
||||
'UnionFlow',
|
||||
style: AppTypography.headerSmall.copyWith(
|
||||
fontSize: 24, // Exception unique pour le logo
|
||||
color: AppColors.primaryGreen,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Text(
|
||||
'Connexion à votre espace.',
|
||||
style: AppTypography.subtitleSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
Widget _buildLoginContent(BuildContext context, AuthState state) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond animé avec dégradé dynamique
|
||||
AnimatedBuilder(
|
||||
animation: _backgroundAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
ColorTokens.background,
|
||||
Color.lerp(
|
||||
ColorTokens.background,
|
||||
ColorTokens.surface,
|
||||
_backgroundAnimation.value * 0.3,
|
||||
)!,
|
||||
ColorTokens.surface,
|
||||
// Champs de texte DRY
|
||||
CoreTextField(
|
||||
controller: _emailController,
|
||||
hintText: 'Email ou Identifiant',
|
||||
prefixIcon: Icons.person_outline,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CoreTextField(
|
||||
controller: _passwordController,
|
||||
hintText: 'Mot de passe',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: true,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => _openForgotPassword(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 0),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'Oublié ?',
|
||||
style: AppTypography.subtitleSmall.copyWith(
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bouton centralisé avec chargement intégré
|
||||
Center(
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator(color: AppColors.primaryGreen)
|
||||
: DynamicFAB(
|
||||
icon: Icons.arrow_forward,
|
||||
label: 'Se Connecter',
|
||||
onPressed: _onLogin,
|
||||
),
|
||||
),
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Éléments décoratifs de fond
|
||||
_buildBackgroundDecoration(),
|
||||
|
||||
// Contenu principal
|
||||
SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: _buildLoginUI(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackgroundDecoration() {
|
||||
return Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _backgroundAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Cercle décoratif haut gauche
|
||||
Positioned(
|
||||
top: -100 + (_backgroundAnimation.value * 30),
|
||||
left: -100 + (_backgroundAnimation.value * 20),
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.primary.withOpacity(0.15),
|
||||
ColorTokens.primary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cercle décoratif bas droit
|
||||
Positioned(
|
||||
bottom: -150 - (_backgroundAnimation.value * 30),
|
||||
right: -120 - (_backgroundAnimation.value * 20),
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.primary.withOpacity(0.12),
|
||||
ColorTokens.primary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cercle décoratif centre
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.height * 0.3,
|
||||
right: -50,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.secondary.withOpacity(0.1),
|
||||
ColorTokens.secondary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginUI() {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.xxxl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Logo et branding premium
|
||||
_buildBranding(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Features cards
|
||||
_buildFeatureCards(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Card de connexion principale
|
||||
_buildLoginCard(),
|
||||
const SizedBox(height: SpacingTokens.xxxl),
|
||||
|
||||
// Footer amélioré
|
||||
_buildFooter(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBranding() {
|
||||
return ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Logo animé avec effet de pulsation
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _pulseAnimation.value,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: ColorTokens.primaryGradient,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.primary.withOpacity(0.3),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 10),
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.account_balance_outlined,
|
||||
size: 32,
|
||||
color: ColorTokens.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xxxl),
|
||||
|
||||
// Titre avec gradient
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
colors: ColorTokens.primaryGradient,
|
||||
).createShader(bounds),
|
||||
child: Text(
|
||||
'Bienvenue',
|
||||
style: TypographyTokens.displaySmall.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -1,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Sous-titre élégant
|
||||
Text(
|
||||
'Connectez-vous à votre espace UnionFlow',
|
||||
style: TypographyTokens.bodyLarge.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureCards() {
|
||||
final features = [
|
||||
{
|
||||
'icon': Icons.account_balance_wallet_rounded,
|
||||
'title': 'Cotisations',
|
||||
'color': ColorTokens.primary,
|
||||
},
|
||||
{
|
||||
'icon': Icons.event_rounded,
|
||||
'title': 'Événements',
|
||||
'color': ColorTokens.secondary,
|
||||
},
|
||||
{
|
||||
'icon': Icons.volunteer_activism_rounded,
|
||||
'title': 'Solidarité',
|
||||
'color': ColorTokens.primary,
|
||||
},
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: features.map((feature) {
|
||||
final index = features.indexOf(feature);
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: index < features.length - 1 ? SpacingTokens.md : 0,
|
||||
),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: 600 + (index * 150)),
|
||||
curve: Curves.easeOutBack,
|
||||
builder: (context, value, child) {
|
||||
return Transform.scale(
|
||||
scale: value,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: SpacingTokens.lg,
|
||||
horizontal: SpacingTokens.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
||||
border: Border.all(
|
||||
color: (feature['color'] as Color).withOpacity(0.15),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.shadow.withOpacity(0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: (feature['color'] as Color).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
),
|
||||
child: Icon(
|
||||
feature['icon'] as IconData,
|
||||
size: 24,
|
||||
color: feature['color'] as Color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Text(
|
||||
feature['title'] as String,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginCard() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXxl),
|
||||
border: Border.all(
|
||||
color: ColorTokens.outline.withOpacity(0.08),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.shadow.withOpacity(0.1),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 12),
|
||||
spreadRadius: -4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.huge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Titre de la card
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.fingerprint_rounded,
|
||||
size: 20,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
Text(
|
||||
'Authentification',
|
||||
style: TypographyTokens.titleMedium.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Bouton de connexion principal
|
||||
_buildLoginButton(),
|
||||
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Divider avec texte
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: ColorTokens.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md),
|
||||
child: Text(
|
||||
'Sécurisé',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: ColorTokens.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Informations de sécurité améliorées
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
border: Border.all(
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.verified_user_rounded,
|
||||
size: 20,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Connexion sécurisée',
|
||||
style: TypographyTokens.labelMedium.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
'Vos données sont protégées et chiffrées',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return Column(
|
||||
children: [
|
||||
// Aide
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: SpacingTokens.lg,
|
||||
vertical: SpacingTokens.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
||||
border: Border.all(
|
||||
color: ColorTokens.outline.withOpacity(0.08),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline_rounded,
|
||||
size: 18,
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Text(
|
||||
'Besoin d\'aide ?',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.8),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xl),
|
||||
|
||||
// Copyright
|
||||
Text(
|
||||
'© 2025 UnionFlow. Tous droits réservés.',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.5),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
'Version 1.0.0',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.4),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoading = state is AuthLoading;
|
||||
|
||||
return UFPrimaryButton(
|
||||
label: 'Se connecter',
|
||||
icon: Icons.login_rounded,
|
||||
onPressed: isLoading ? null : _handleLogin,
|
||||
isLoading: isLoading,
|
||||
isFullWidth: true,
|
||||
height: 56,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLogin() {
|
||||
// Démarrer l'authentification Keycloak
|
||||
context.read<AuthBloc>().add(const AuthLoginRequested());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user