import 'dart:math' as math; 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:url_launcher/url_launcher.dart'; import '../bloc/auth_bloc.dart'; import '../../../../core/config/environment.dart'; /// UnionFlow — Écran de connexion premium /// Gradient forêt + glassmorphism + animations + biométrie + remember me class LoginPage extends StatefulWidget { const LoginPage({super.key}); @override State createState() => _LoginPageState(); } class _LoginPageState extends State with TickerProviderStateMixin { late final AnimationController _fadeController; late final AnimationController _slideController; late final Animation _fadeAnim; late final Animation _slideAnim; bool _biometricAvailable = false; final _localAuth = LocalAuthentication(); static const _gradTop = Color(0xFF1B5E20); static const _gradMid = Color(0xFF2E7D32); static const _gradBot = Color(0xFF388E3C); static const _primaryGreen = Color(0xFF2E7D32); @override void initState() { super.initState(); _fadeController = AnimationController(vsync: this, duration: const Duration(milliseconds: 900)); _slideController = AnimationController(vsync: this, duration: const Duration(milliseconds: 750)); _fadeAnim = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut); _slideAnim = Tween(begin: const Offset(0, 0.12), end: Offset.zero) .animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic)); _fadeController.forward(); _slideController.forward(); _checkBiometrics(); } @override void dispose() { _fadeController.dispose(); _slideController.dispose(); super.dispose(); } Future _checkBiometrics() async { try { final canCheck = await _localAuth.canCheckBiometrics; final supported = await _localAuth.isDeviceSupported(); if (mounted) setState(() => _biometricAvailable = canCheck && supported); } catch (_) {} } Future _authenticateBiometric() async { try { final ok = await _localAuth.authenticate( localizedReason: 'Authentifiez-vous pour accéder à UnionFlow', options: const AuthenticationOptions(stickyAuth: true, biometricOnly: false), ); if (ok && mounted) { context.read().add(const AuthStatusChecked()); } } catch (_) {} } Future _openForgotPassword() 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', ); try { if (await canLaunchUrl(url)) await launchUrl(url, mode: LaunchMode.externalApplication); } catch (_) {} } void _onLogin() { context.read().add(const AuthLoginRequested()); } @override Widget build(BuildContext context) { return Scaffold( body: BlocConsumer( listener: (context, state) { if (state is AuthError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), backgroundColor: const Color(0xFFB71C1C), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ); } }, builder: (context, state) { final isLoading = state is AuthLoading; return Stack( children: [ // Gradient background Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [_gradTop, _gradMid, _gradBot], stops: [0.0, 0.55, 1.0], ), ), ), // Subtle hexagon pattern overlay const Positioned.fill(child: _HexPatternOverlay()), // Content SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), child: FadeTransition( opacity: _fadeAnim, child: SlideTransition( position: _slideAnim, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildLogoSection(), const SizedBox(height: 16), _buildGlassCard(isLoading), ], ), ), ), ), ), ), ], ); }, ), ); } Widget _buildLogoSection() { return Column( children: [ CustomPaint( size: const Size(48, 48), painter: _HexLogoMark(), ), const SizedBox(height: 10), Text( 'UnionFlow', style: GoogleFonts.roboto( fontSize: 34, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, ), ), const SizedBox(height: 6), Text( 'Gérez votre organisation avec sérénité', style: GoogleFonts.roboto( fontSize: 13, fontWeight: FontWeight.w400, color: Colors.white.withOpacity(0.78), letterSpacing: 0.2, ), textAlign: TextAlign.center, ), ], ); } Widget _buildGlassCard(bool isLoading) { return Container( decoration: BoxDecoration( color: Colors.white.withOpacity(0.11), borderRadius: BorderRadius.circular(16), border: Border.all( color: Colors.white.withOpacity(0.22), width: 1.5, ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.18), blurRadius: 40, offset: const Offset(0, 10), ), ], ), padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Connexion', style: GoogleFonts.roboto( fontSize: 22, fontWeight: FontWeight.w600, color: Colors.white, letterSpacing: 0.1, ), ), const SizedBox(height: 4), Text( 'Accédez à votre espace de travail', style: GoogleFonts.roboto( fontSize: 12.5, color: Colors.white.withOpacity(0.68), letterSpacing: 0.1, ), ), const SizedBox(height: 12), // Forgot password Align( alignment: Alignment.centerRight, child: 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), ), ), ), ), const SizedBox(height: 16), // Login button isLoading ? const Center(child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) : ElevatedButton( onPressed: _onLogin, style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: _primaryGreen, padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), elevation: 0, ), child: Text( 'Se connecter', style: GoogleFonts.roboto( fontSize: 15.5, fontWeight: FontWeight.w700, color: _primaryGreen, letterSpacing: 0.2, ), ), ), // Biometric if (_biometricAvailable) ...[ const SizedBox(height: 14), Center( child: TextButton.icon( onPressed: _authenticateBiometric, icon: const Icon(Icons.fingerprint_rounded, color: Colors.white60, size: 22), label: Text( 'Connexion biométrique', style: GoogleFonts.roboto(fontSize: 12.5, color: Colors.white60), ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), ), ), ), ], ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // Painters // ───────────────────────────────────────────────────────────────────────────── /// Motif hexagonal en overlay sur le dégradé (opacité 4%) class _HexPatternOverlay extends StatelessWidget { const _HexPatternOverlay(); @override Widget build(BuildContext context) { return CustomPaint(painter: _HexPatternPainter()); } } class _HexPatternPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = Colors.white.withOpacity(0.045) ..style = PaintingStyle.stroke ..strokeWidth = 0.8; const r = 22.0; const hSpace = r * 1.75; const vSpace = r * 1.52; for (double row = -1; row * vSpace < size.height + vSpace; row++) { final offset = (row % 2 == 0) ? 0.0 : hSpace / 2; for (double col = -1; col * hSpace - offset < size.width + hSpace; col++) { _hexagon(canvas, paint, Offset(col * hSpace + offset, row * vSpace), r); } } } void _hexagon(Canvas canvas, Paint paint, Offset center, double r) { final path = Path(); for (int i = 0; i < 6; i++) { final a = (i * 60 - 30) * math.pi / 180; final p = Offset(center.dx + r * math.cos(a), center.dy + r * math.sin(a)); if (i == 0) path.moveTo(p.dx, p.dy); else path.lineTo(p.dx, p.dy); } path.close(); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } /// Logo hexagonal avec initiales "UF" class _HexLogoMark extends CustomPainter { @override void paint(Canvas canvas, Size size) { final cx = size.width / 2; final cy = size.height / 2; final r = size.width / 2 - 2; // Fond hexagonal blanc semi-transparent final bgPaint = Paint() ..color = Colors.white.withOpacity(0.18) ..style = PaintingStyle.fill; final borderPaint = Paint() ..color = Colors.white.withOpacity(0.6) ..style = PaintingStyle.stroke ..strokeWidth = 1.8; final path = Path(); for (int i = 0; i < 6; i++) { final a = (i * 60 - 30) * math.pi / 180; final p = Offset(cx + r * math.cos(a), cy + r * math.sin(a)); if (i == 0) path.moveTo(p.dx, p.dy); else path.lineTo(p.dx, p.dy); } path.close(); canvas.drawPath(path, bgPaint); canvas.drawPath(path, borderPaint); // Lignes stylisées "UF" dessinées (plus propre qu'un TextPainter dans un painter) final linePaint = Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = 2.8 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; // Lettre U final uPath = Path() ..moveTo(cx - 13, cy - 10) ..lineTo(cx - 13, cy + 5) ..quadraticBezierTo(cx - 13, cy + 12, cx - 7, cy + 12) ..quadraticBezierTo(cx - 1, cy + 12, cx - 1, cy + 5) ..lineTo(cx - 1, cy - 10); canvas.drawPath(uPath, linePaint); // Lettre F canvas.drawLine(Offset(cx + 3, cy - 10), Offset(cx + 3, cy + 12), linePaint); canvas.drawLine(Offset(cx + 3, cy - 10), Offset(cx + 13, cy - 10), linePaint); canvas.drawLine(Offset(cx + 3, cy + 1), Offset(cx + 11, cy + 1), linePaint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }