Refactoring - Version OK

This commit is contained in:
dahoud
2025-11-17 16:02:04 +00:00
parent 3f00a26308
commit 3b9ffac8cd
198 changed files with 18010 additions and 11383 deletions

View File

@@ -0,0 +1,468 @@
/// 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 '../../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';
// === É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);
@override
List<Object?> get props => [organizationId];
}
/// É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];
}
// === ÉTATS ===
/// États d'authentification
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
/// État initial
class AuthInitial extends AuthState {
const AuthInitial();
}
/// É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;
const AuthAuthenticated({
required this.user,
this.currentOrganizationId,
required this.effectiveRole,
required this.effectivePermissions,
required this.authenticatedAt,
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,
];
}
/// État non authentifié
class AuthUnauthenticated extends AuthState {
final String? message;
const AuthUnauthenticated({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
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(const AuthInitial()) {
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthOrganizationContextChanged>(_onOrganizationContextChanged);
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
on<AuthStatusChecked>(_onStatusChecked);
on<AuthUserProfileUpdated>(_onUserProfileUpdated);
on<AuthWebViewCallback>(_onWebViewCallback);
}
/// 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());
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!;
} else {
debugPrint('🔄 Traitement du callback URL: ${event.callbackUrl}');
user = await KeycloakAuthService.handleWebViewCallback(event.callbackUrl);
}
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'));
}
}
/// 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'));
}
}
/// Vérifie l'état d'authentification Keycloak
Future<void> _onStatusChecked(
AuthStatusChecked event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
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;
}
// 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'));
}
}
}

View File

@@ -0,0 +1,583 @@
/// Page d'Authentification UnionFlow
///
/// Interface utilisateur pour la connexion sécurisée
/// avec gestion complète des états et des erreurs.
library keycloak_webview_auth_page;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../data/datasources/keycloak_webview_auth_service.dart';
import '../../data/models/user.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
import '../../../../shared/design_system/tokens/typography_tokens.dart';
/// États de l'authentification WebView
enum KeycloakWebViewAuthState {
/// Initialisation en cours
initializing,
/// Chargement de la page d'authentification
loading,
/// Page d'authentification affichée
ready,
/// Authentification en cours
authenticating,
/// Authentification réussie
success,
/// Erreur d'authentification
error,
/// Timeout
timeout,
}
/// Page d'authentification Keycloak avec WebView
class KeycloakWebViewAuthPage extends StatefulWidget {
/// Callback appelé en cas de succès d'authentification
final Function(User user) onAuthSuccess;
/// Callback appelé en cas d'erreur
final Function(String error) onAuthError;
/// Callback appelé en cas d'annulation
final VoidCallback? onAuthCancel;
/// Timeout pour l'authentification (en secondes)
final int timeoutSeconds;
const KeycloakWebViewAuthPage({
super.key,
required this.onAuthSuccess,
required this.onAuthError,
this.onAuthCancel,
this.timeoutSeconds = 300, // 5 minutes par défaut
});
@override
State<KeycloakWebViewAuthPage> createState() => _KeycloakWebViewAuthPageState();
}
class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
with TickerProviderStateMixin {
// Contrôleurs et état
late WebViewController _webViewController;
late AnimationController _progressAnimationController;
late Animation<double> _progressAnimation;
Timer? _timeoutTimer;
// État de l'authentification
KeycloakWebViewAuthState _authState = KeycloakWebViewAuthState.initializing;
String? _errorMessage;
double _loadingProgress = 0.0;
// Paramètres d'authentification
String? _authUrl;
@override
void initState() {
super.initState();
_initializeAnimations();
_initializeAuthentication();
}
@override
void dispose() {
_progressAnimationController.dispose();
_timeoutTimer?.cancel();
super.dispose();
}
/// Initialise les animations
void _initializeAnimations() {
_progressAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_progressAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _progressAnimationController,
curve: Curves.easeInOut,
));
}
/// Initialise l'authentification
Future<void> _initializeAuthentication() async {
try {
debugPrint('🚀 Initialisation de l\'authentification WebView...');
setState(() {
_authState = KeycloakWebViewAuthState.initializing;
});
// Préparer l'authentification
final Map<String, String> authParams =
await KeycloakWebViewAuthService.prepareAuthentication();
_authUrl = authParams['url'];
if (_authUrl == null) {
throw Exception('URL d\'authentification manquante');
}
// Initialiser la WebView
await _initializeWebView();
// Démarrer le timer de timeout
_startTimeoutTimer();
debugPrint('✅ Authentification initialisée avec succès');
} catch (e) {
debugPrint('💥 Erreur initialisation authentification: $e');
_handleError('Erreur d\'initialisation: $e');
}
}
/// Initialise la WebView
Future<void> _initializeWebView() async {
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(ColorTokens.surface)
..setNavigationDelegate(
NavigationDelegate(
onProgress: _onLoadingProgress,
onPageStarted: _onPageStarted,
onPageFinished: _onPageFinished,
onWebResourceError: _onWebResourceError,
onNavigationRequest: _onNavigationRequest,
),
);
// Charger l'URL d'authentification
if (_authUrl != null) {
await _webViewController.loadRequest(Uri.parse(_authUrl!));
setState(() {
_authState = KeycloakWebViewAuthState.loading;
});
}
}
/// Démarre le timer de timeout
void _startTimeoutTimer() {
_timeoutTimer = Timer(Duration(seconds: widget.timeoutSeconds), () {
if (_authState != KeycloakWebViewAuthState.success) {
debugPrint('⏰ Timeout d\'authentification atteint');
_handleTimeout();
}
});
}
/// Gère la progression du chargement
void _onLoadingProgress(int progress) {
setState(() {
_loadingProgress = progress / 100.0;
});
if (progress == 100) {
_progressAnimationController.forward();
}
}
/// Gère le début du chargement d'une page
void _onPageStarted(String url) {
debugPrint('📄 Chargement de la page: $url');
setState(() {
_loadingProgress = 0.0;
});
_progressAnimationController.reset();
}
/// Gère la fin du chargement d'une page
void _onPageFinished(String url) {
debugPrint('✅ Page chargée: $url');
setState(() {
if (_authState == KeycloakWebViewAuthState.loading) {
_authState = KeycloakWebViewAuthState.ready;
}
});
}
/// Gère les erreurs de ressources web
void _onWebResourceError(WebResourceError error) {
debugPrint('💥 Erreur WebView: ${error.description}');
// Ignorer certaines erreurs non critiques
if (error.errorCode == -999) { // Code d'erreur pour annulation
return;
}
_handleError('Erreur de chargement: ${error.description}');
}
/// Gère les requêtes de navigation
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final String url = request.url;
debugPrint('🔗 Navigation vers: $url');
// Vérifier si c'est notre URL de callback
if (url.startsWith('dev.lions.unionflow-mobile://auth/callback')) {
debugPrint('🎯 URL de callback détectée: $url');
_handleAuthCallback(url);
return NavigationDecision.prevent;
}
// Vérifier d'autres patterns de callback possibles
if (url.contains('code=') && url.contains('state=')) {
debugPrint('🎯 Callback potentiel détecté (avec code et state): $url');
_handleAuthCallback(url);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
/// Traite le callback d'authentification
Future<void> _handleAuthCallback(String callbackUrl) async {
try {
setState(() {
_authState = KeycloakWebViewAuthState.authenticating;
});
debugPrint('🔄 Traitement du callback d\'authentification...');
debugPrint('📋 URL de callback reçue: $callbackUrl');
// Traiter le callback via le service
final User user = await KeycloakWebViewAuthService.handleAuthCallback(callbackUrl);
setState(() {
_authState = KeycloakWebViewAuthState.success;
});
// Annuler le timer de timeout
_timeoutTimer?.cancel();
debugPrint('🎉 Authentification réussie pour: ${user.fullName}');
debugPrint('👤 Rôle: ${user.primaryRole.displayName}');
debugPrint('🔐 Permissions: ${user.additionalPermissions.length}');
// Notifier le succès avec un délai pour l'animation
Future.delayed(const Duration(milliseconds: 500), () {
widget.onAuthSuccess(user);
});
} catch (e, stackTrace) {
debugPrint('💥 Erreur traitement callback: $e');
debugPrint('📋 Stack trace: $stackTrace');
// Essayer de donner plus d'informations sur l'erreur
String errorMessage = 'Erreur d\'authentification: $e';
if (e.toString().contains('MISSING_AUTH_STATE')) {
errorMessage = 'Session expirée. Veuillez réessayer.';
} else if (e.toString().contains('INVALID_STATE')) {
errorMessage = 'Erreur de sécurité. Veuillez réessayer.';
} else if (e.toString().contains('MISSING_AUTH_CODE')) {
errorMessage = 'Code d\'autorisation manquant. Veuillez réessayer.';
}
_handleError(errorMessage);
}
}
/// Gère les erreurs
void _handleError(String error) {
setState(() {
_authState = KeycloakWebViewAuthState.error;
_errorMessage = error;
});
_timeoutTimer?.cancel();
// Vibration pour indiquer l'erreur
HapticFeedback.lightImpact();
widget.onAuthError(error);
}
/// Gère le timeout
void _handleTimeout() {
setState(() {
_authState = KeycloakWebViewAuthState.timeout;
_errorMessage = 'Timeout d\'authentification atteint';
});
HapticFeedback.lightImpact();
widget.onAuthError('Timeout d\'authentification');
}
/// Gère l'annulation
void _handleCancel() {
debugPrint('❌ Authentification annulée par l\'utilisateur');
_timeoutTimer?.cancel();
if (widget.onAuthCancel != null) {
widget.onAuthCancel!();
} else {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.surface,
appBar: _buildAppBar(),
body: _buildBody(),
);
}
/// Construit l'AppBar
PreferredSizeWidget _buildAppBar() {
return AppBar(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
elevation: 0,
title: Text(
'Connexion Sécurisée',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onPrimary,
fontWeight: FontWeight.w600,
),
),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _handleCancel,
tooltip: 'Annuler',
),
actions: [
if (_authState == KeycloakWebViewAuthState.ready)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _webViewController.reload(),
tooltip: 'Actualiser',
),
],
bottom: _buildProgressIndicator(),
);
}
/// Construit l'indicateur de progression
PreferredSizeWidget? _buildProgressIndicator() {
if (_authState == KeycloakWebViewAuthState.loading ||
_authState == KeycloakWebViewAuthState.authenticating) {
return PreferredSize(
preferredSize: const Size.fromHeight(4.0),
child: AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
return LinearProgressIndicator(
value: _authState == KeycloakWebViewAuthState.authenticating
? null
: _loadingProgress,
backgroundColor: ColorTokens.onPrimary.withOpacity(0.3),
valueColor: const AlwaysStoppedAnimation<Color>(ColorTokens.onPrimary),
);
},
),
);
}
return null;
}
/// Construit le corps de la page
Widget _buildBody() {
switch (_authState) {
case KeycloakWebViewAuthState.initializing:
return _buildInitializingView();
case KeycloakWebViewAuthState.loading:
case KeycloakWebViewAuthState.ready:
return _buildWebView();
case KeycloakWebViewAuthState.authenticating:
return _buildAuthenticatingView();
case KeycloakWebViewAuthState.success:
return _buildSuccessView();
case KeycloakWebViewAuthState.error:
case KeycloakWebViewAuthState.timeout:
return _buildErrorView();
}
}
/// Vue d'initialisation
Widget _buildInitializingView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: SpacingTokens.xl),
Text(
'Initialisation...',
style: TypographyTokens.bodyLarge.copyWith(
color: ColorTokens.onSurface,
),
),
],
),
);
}
/// Vue WebView
Widget _buildWebView() {
return WebViewWidget(controller: _webViewController);
}
/// Vue d'authentification en cours
Widget _buildAuthenticatingView() {
return Container(
color: ColorTokens.surface,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: SpacingTokens.xxxl),
Text(
'Connexion en cours...',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
'Veuillez patienter pendant que nous\nvérifions vos informations.',
textAlign: TextAlign.center,
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
/// Vue de succès
Widget _buildSuccessView() {
return Container(
color: ColorTokens.surface,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 48,
),
),
const SizedBox(height: SpacingTokens.xxxl),
Text(
'Connexion réussie !',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
'Redirection vers l\'application...',
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
/// Vue d'erreur
Widget _buildErrorView() {
return Container(
color: ColorTokens.surface,
padding: const EdgeInsets.all(SpacingTokens.xxxl),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: ColorTokens.error,
shape: BoxShape.circle,
),
child: Icon(
_authState == KeycloakWebViewAuthState.timeout
? Icons.access_time
: Icons.error_outline,
color: ColorTokens.onError,
size: 48,
),
),
const SizedBox(height: SpacingTokens.xxxl),
Text(
_authState == KeycloakWebViewAuthState.timeout
? 'Délai d\'attente dépassé'
: 'Erreur de connexion',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
_errorMessage ?? 'Une erreur inattendue s\'est produite',
textAlign: TextAlign.center,
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: SpacingTokens.huge),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: _initializeAuthentication,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
),
OutlinedButton.icon(
onPressed: _handleCancel,
icon: const Icon(Icons.close),
label: const Text('Annuler'),
style: OutlinedButton.styleFrom(
foregroundColor: ColorTokens.onSurface,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,738 @@
/// 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';
/// Page de connexion UnionFlow
/// Présente l'application et permet l'authentification sécurisée
class LoginPage extends StatefulWidget {
const LoginPage({super.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();
}
@override
void dispose() {
_animationController.dispose();
_backgroundController.dispose();
_pulseController.dispose();
super.dispose();
}
void _initializeAnimations() {
// Animation principale d'entrée
_animationController = AnimationController(
duration: const Duration(milliseconds: 1400),
vsync: this,
);
_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();
}
/// 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)}...');
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');
}
@override
Widget build(BuildContext context) {
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');
} 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,
),
);
} 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);
});
}
return _buildLoginContent(context, state);
},
),
);
}
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,
],
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());
}
}