fix(mobile): URL changement mdp corrigée + v3.0 — multi-org, AppAuth, sécurité prod

Auth:
- profile_repository.dart: /api/auth/change-password → /api/membres/auth/change-password

Multi-org (Phase 3):
- OrgSelectorPage, OrgSwitcherBloc, OrgSwitcherEntry
- org_context_service.dart: headers X-Active-Organisation-Id + X-Active-Role

Navigation:
- MorePage: navigation conditionnelle par typeOrganisation
- Suppression adaptive_navigation (remplacé par main_navigation_layout)

Auth AppAuth:
- keycloak_webview_auth_service: fixes AppAuth Android
- AuthBloc: gestion REAUTH_REQUIS + premierLoginComplet

Onboarding:
- Nouveaux états: payment_method_page, onboarding_shared_widgets
- SouscriptionStatusModel mis à jour StatutValidationSouscription

Android:
- build.gradle: ProGuard/R8, network_security_config
- Gradle wrapper mis à jour
This commit is contained in:
dahoud
2026-04-07 20:56:03 +00:00
parent 22f9c7e9a1
commit 70cbd1c873
63 changed files with 9316 additions and 6122 deletions

View File

@@ -6,6 +6,7 @@ import '../../data/models/user_role.dart';
import '../../data/datasources/keycloak_auth_service.dart';
import '../../data/datasources/permission_engine.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/network/org_context_service.dart';
import '../../../../core/storage/dashboard_cache_manager.dart';
import '../../../../core/utils/logger.dart';
import '../../../../core/di/injection.dart';
@@ -87,16 +88,32 @@ class AuthPendingOnboarding extends AuthState {
List<Object?> get props => [onboardingState, souscriptionId, organisationId, typeOrganisation];
}
// Nouvel événement : auto-select l'org active après login (pour membres mono-org)
class AuthOrgContextInitRequested extends AuthEvent {
final String organisationId;
final String organisationNom;
final String? type;
const AuthOrgContextInitRequested({
required this.organisationId,
required this.organisationNom,
this.type,
});
@override
List<Object?> get props => [organisationId, organisationNom, type];
}
// === BLOC ===
@lazySingleton
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final KeycloakAuthService _authService;
final OrgContextService _orgContextService;
AuthBloc(this._authService) : super(AuthInitial()) {
AuthBloc(this._authService, this._orgContextService) : super(AuthInitial()) {
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthStatusChecked>(_onStatusChecked);
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
on<AuthOrgContextInitRequested>(_onOrgContextInit);
}
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
@@ -185,9 +202,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
emit(AuthLoading());
await _authService.logout();
await DashboardCacheManager.clear();
_orgContextService.clear();
emit(AuthUnauthenticated());
}
Future<void> _onOrgContextInit(
AuthOrgContextInitRequested event,
Emitter<AuthState> emit,
) async {
_orgContextService.setActiveOrganisation(
organisationId: event.organisationId,
nom: event.organisationNom,
type: event.type,
);
AppLogger.info('AuthBloc: contexte org initialisé → ${event.organisationNom}');
}
Future<void> _onStatusChecked(AuthStatusChecked event, Emitter<AuthState> emit) async {
final tokenValid = await _authService.getValidToken();
final isAuth = tokenValid != null;
@@ -276,9 +306,18 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
///
/// Si le rôle est [UserRole.orgAdmin] et que [organizationContexts] est vide,
/// appelle GET /api/organisations/mes pour récupérer les organisations de l'admin.
/// Auto-initialise [OrgContextService] si une seule organisation.
Future<User> _enrichUserWithOrgContext(User user) async {
if (user.primaryRole != UserRole.orgAdmin ||
user.organizationContexts.isNotEmpty) {
// Auto-select le premier contexte existant si pas encore de contexte actif
if (!_orgContextService.hasContext && user.organizationContexts.isNotEmpty) {
final first = user.organizationContexts.first;
_orgContextService.setActiveOrganisation(
organisationId: first.organizationId,
nom: first.organizationName,
);
}
return user;
}
try {
@@ -296,7 +335,15 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
),
)
.toList();
return contexts.isEmpty ? user : user.copyWith(organizationContexts: contexts);
if (contexts.isEmpty) return user;
// Auto-select si une seule organisation
if (contexts.length == 1 && !_orgContextService.hasContext) {
_orgContextService.setActiveOrganisation(
organisationId: contexts.first.organizationId,
nom: contexts.first.organizationName,
);
}
return user.copyWith(organizationContexts: contexts);
} catch (e) {
AppLogger.warning('AuthBloc: impossible de charger le contexte org: $e');
return user;

View File

@@ -1,327 +1,121 @@
/// Page d'Authentification UnionFlow
///
/// Interface utilisateur pour la connexion sécurisée
/// avec gestion complète des états et des erreurs.
/// Interface utilisateur pour la connexion sécurisée via AppAuth (RFC 8252).
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 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import '../../data/datasources/keycloak_auth_service.dart';
import '../../data/datasources/keycloak_role_mapper.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
/// Page d'authentification Keycloak via AppAuth
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
this.timeoutSeconds = 300,
});
@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;
class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage> {
bool _loading = true;
String? _errorMessage;
double _loadingProgress = 0.0;
// Paramètres d'authentification
String? _authUrl;
static const _appAuth = FlutterAppAuth();
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
@override
void initState() {
super.initState();
_initializeAnimations();
_initializeAuthentication();
_authenticate();
}
@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 {
Future<void> _authenticate() 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,
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
KeycloakConfig.clientId,
'dev.lions.unionflow-mobile://auth/callback',
serviceConfiguration: AuthorizationServiceConfiguration(
authorizationEndpoint:
'${KeycloakConfig.baseUrl}/realms/${KeycloakConfig.realm}/protocol/openid-connect/auth',
tokenEndpoint: KeycloakConfig.tokenEndpoint,
),
scopes: ['openid', 'profile', 'email', 'roles', 'offline_access'],
additionalParameters: {'kc_locale': 'fr'},
allowInsecureConnections: true,
),
);
// 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.';
if (result?.accessToken == null) {
_onError('Authentification annulée ou échouée.');
return;
}
_handleError(errorMessage);
await _storage.write(key: 'kc_access', value: result!.accessToken);
if (result.refreshToken != null) {
await _storage.write(key: 'kc_refresh', value: result.refreshToken);
}
if (result.idToken != null) {
await _storage.write(key: 'kc_id', value: result.idToken);
}
final accessPayload = JwtDecoder.decode(result.accessToken!);
final idPayload = result.idToken != null ? JwtDecoder.decode(result.idToken!) : accessPayload;
final roles = _extractRoles(accessPayload);
final primaryRole = KeycloakRoleMapper.mapToUserRole(roles);
final user = User(
id: idPayload['sub'] ?? '',
email: idPayload['email'] ?? '',
firstName: idPayload['given_name'] ?? '',
lastName: idPayload['family_name'] ?? '',
primaryRole: primaryRole,
additionalPermissions: KeycloakRoleMapper.mapToPermissions(roles),
isActive: true,
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
);
if (mounted) {
setState(() => _loading = false);
Future.delayed(const Duration(milliseconds: 300), () {
widget.onAuthSuccess(user);
});
}
} catch (e) {
_onError('Erreur d\'authentification: $e');
}
}
/// Gère les erreurs
void _handleError(String error) {
setState(() {
_authState = KeycloakWebViewAuthState.error;
_errorMessage = error;
});
_timeoutTimer?.cancel();
// Vibration pour indiquer l'erreur
void _onError(String error) {
HapticFeedback.lightImpact();
if (mounted) setState(() { _loading = false; _errorMessage = error; });
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 {
@@ -329,92 +123,45 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
}
}
List<String> _extractRoles(Map<String, dynamic> payload) {
final roles = <String>[];
if (payload['realm_access']?['roles'] != null) {
roles.addAll((payload['realm_access']['roles'] as List).cast<String>());
}
if (payload['resource_access'] != null) {
(payload['resource_access'] as Map).values.forEach((v) {
if (v['roles'] != null) roles.addAll((v['roles'] as List).cast<String>());
});
}
return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList();
}
@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',
appBar: 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,
),
],
bottom: _buildProgressIndicator(),
),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _handleCancel,
tooltip: 'Annuler',
),
),
body: _errorMessage != null ? _buildErrorView() : _buildLoadingView(),
);
}
/// 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() {
Widget _buildLoadingView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -422,95 +169,14 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
const CircularProgressIndicator(),
const SizedBox(height: SpacingTokens.xl),
Text(
'Initialisation...',
style: TypographyTokens.bodyLarge.copyWith(
color: ColorTokens.onSurface,
),
'Connexion en cours...',
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,
@@ -526,19 +192,11 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
color: ColorTokens.error,
shape: BoxShape.circle,
),
child: Icon(
_authState == KeycloakWebViewAuthState.timeout
? Icons.access_time
: Icons.error_outline,
color: ColorTokens.onError,
size: 48,
),
child: const Icon(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',
'Erreur de connexion',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
@@ -557,7 +215,10 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: _initializeAuthentication,
onPressed: () {
setState(() { _loading = true; _errorMessage = null; });
_authenticate();
},
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(

View File

@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:local_auth/local_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import '../bloc/auth_bloc.dart';
@@ -20,16 +19,11 @@ class LoginPage extends StatefulWidget {
}
class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
late final AnimationController _fadeController;
late final AnimationController _slideController;
late final Animation<double> _fadeAnim;
late final Animation<Offset> _slideAnim;
bool _obscurePassword = true;
bool _rememberMe = false;
bool _biometricAvailable = false;
final _localAuth = LocalAuthentication();
@@ -50,15 +44,12 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
_fadeController.forward();
_slideController.forward();
_checkBiometrics();
_loadSavedCredentials();
}
@override
void dispose() {
_fadeController.dispose();
_slideController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@@ -70,17 +61,6 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
} catch (_) {}
}
Future<void> _loadSavedCredentials() async {
final prefs = await SharedPreferences.getInstance();
final remember = prefs.getBool('uf_remember_me') ?? false;
if (remember && mounted) {
setState(() {
_rememberMe = true;
_emailController.text = prefs.getString('uf_saved_email') ?? '';
});
}
}
Future<void> _authenticateBiometric() async {
try {
final ok = await _localAuth.authenticate(
@@ -88,12 +68,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: false),
);
if (ok && mounted) {
final prefs = await SharedPreferences.getInstance();
final email = prefs.getString('uf_saved_email') ?? '';
final pass = prefs.getString('uf_saved_pass') ?? '';
if (email.isNotEmpty && pass.isNotEmpty) {
context.read<AuthBloc>().add(AuthLoginRequested(email, pass));
}
context.read<AuthBloc>().add(const AuthStatusChecked());
}
} catch (_) {}
}
@@ -110,24 +85,8 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
} catch (_) {}
}
Future<void> _onLogin() async {
final email = _emailController.text.trim();
final password = _passwordController.text;
if (email.isEmpty || password.isEmpty) return;
if (_rememberMe) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('uf_remember_me', true);
await prefs.setString('uf_saved_email', email);
await prefs.setString('uf_saved_pass', password);
} else {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('uf_remember_me');
await prefs.remove('uf_saved_email');
await prefs.remove('uf_saved_pass');
}
if (mounted) context.read<AuthBloc>().add(AuthLoginRequested(email, password));
void _onLogin() {
context.read<AuthBloc>().add(const AuthLoginRequested());
}
@override
@@ -267,51 +226,27 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
),
const SizedBox(height: 12),
_GlassTextField(
controller: _emailController,
hint: 'Email ou identifiant',
icon: Icons.person_outline_rounded,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 8),
_GlassTextField(
controller: _passwordController,
hint: 'Mot de passe',
icon: Icons.lock_outline_rounded,
isPassword: true,
obscure: _obscurePassword,
onToggleObscure: () => setState(() => _obscurePassword = !_obscurePassword),
),
const SizedBox(height: 8),
// Remember me + Forgot password
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_RememberMeToggle(
value: _rememberMe,
onChanged: (v) => setState(() => _rememberMe = v),
// Forgot password
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _openForgotPassword,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
TextButton(
onPressed: _openForgotPassword,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Mot de passe oublié ?',
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: Colors.white.withOpacity(0.7),
),
child: Text(
'Mot de passe oublié ?',
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: Colors.white.withOpacity(0.7),
),
),
],
),
),
const SizedBox(height: 16),
@@ -361,108 +296,6 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sous-composants privés
// ─────────────────────────────────────────────────────────────────────────────
class _GlassTextField extends StatelessWidget {
const _GlassTextField({
required this.controller,
required this.hint,
required this.icon,
this.keyboardType,
this.isPassword = false,
this.obscure = false,
this.onToggleObscure,
});
final TextEditingController controller;
final String hint;
final IconData icon;
final TextInputType? keyboardType;
final bool isPassword;
final bool obscure;
final VoidCallback? onToggleObscure;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.13),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white.withOpacity(0.28), width: 1),
),
child: TextField(
controller: controller,
obscureText: isPassword && obscure,
keyboardType: keyboardType,
style: GoogleFonts.roboto(fontSize: 15, color: Colors.white),
decoration: InputDecoration(
hintText: hint,
hintStyle: GoogleFonts.roboto(fontSize: 14.5, color: Colors.white.withOpacity(0.48)),
prefixIcon: Icon(icon, color: Colors.white54, size: 20),
suffixIcon: isPassword
? IconButton(
icon: Icon(
obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
color: Colors.white54,
size: 20,
),
onPressed: onToggleObscure,
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
),
),
);
}
}
class _RememberMeToggle extends StatelessWidget {
const _RememberMeToggle({required this.value, required this.onChanged});
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: Checkbox(
value: value,
onChanged: (v) => onChanged(v ?? false),
fillColor: WidgetStateProperty.resolveWith((s) {
if (s.contains(WidgetState.selected)) return Colors.white;
return Colors.transparent;
}),
checkColor: const Color(0xFF2E7D32),
side: const BorderSide(color: Colors.white60, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 7),
Text(
'Se souvenir de moi',
style: GoogleFonts.roboto(
fontSize: 12,
color: Colors.white.withOpacity(0.78),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Painters
// ─────────────────────────────────────────────────────────────────────────────