/// 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 createState() => _KeycloakWebViewAuthPageState(); } class _KeycloakWebViewAuthPageState extends State with TickerProviderStateMixin { // Contrôleurs et état late WebViewController _webViewController; late AnimationController _progressAnimationController; late Animation _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( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: _progressAnimationController, curve: Curves.easeInOut, )); } /// Initialise l'authentification Future _initializeAuthentication() async { try { debugPrint('🚀 Initialisation de l\'authentification WebView...'); setState(() { _authState = KeycloakWebViewAuthState.initializing; }); // Préparer l'authentification final Map 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 _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 _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(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, ), ), ], ), ], ), ), ); } }