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:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

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

View File

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