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'; import '../../../../shared/design_system/unionflow_design_system.dart'; import '../../../../shared/widgets/powered_by_lions_dev.dart'; // ── Couleurs signature ──────────────────────────────────────────────────────── const _kGradTop = Color(0xFF1D4ED8); const _kGradMid = Color(0xFF2563EB); const _kGradBot = Color(0xFF7616E8); const _kPrimaryBlue = Color(0xFF2563EB); /// UnionFlow — Écran de connexion. /// Gradient signature (#1D4ED8 → #2563EB → #7616E8) + glassmorphism. class LoginPage extends StatefulWidget { const LoginPage({super.key}); @override State createState() => _LoginPageState(); } class _LoginPageState extends State with TickerProviderStateMixin { late final AnimationController _fadeCtrl; late final AnimationController _slideCtrl; late final Animation _fadeAnim; late final Animation _slideAnim; bool _biometricAvailable = false; final _localAuth = LocalAuthentication(); @override void initState() { super.initState(); _fadeCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 900)); _slideCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 750)); _fadeAnim = CurvedAnimation(parent: _fadeCtrl, curve: Curves.easeOut); _slideAnim = Tween(begin: const Offset(0, 0.12), end: Offset.zero) .animate(CurvedAnimation(parent: _slideCtrl, curve: Curves.easeOutCubic)); _fadeCtrl.forward(); _slideCtrl.forward(); _checkBiometrics(); } @override void dispose() { _fadeCtrl.dispose(); _slideCtrl.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 _onAuthStateChanged(BuildContext context, AuthState state) { if (state is AuthError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), backgroundColor: AppColors.error, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ); } } @override Widget build(BuildContext context) { return Scaffold( body: BlocConsumer( listener: _onAuthStateChanged, builder: (context, state) { final isLoading = state is AuthLoading; return Stack( children: [ const _GradientBackground(), const Positioned.fill(child: _HexPatternOverlay()), 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: [ const _LoginLogoSection(), const SizedBox(height: 16), _LoginGlassCard( isLoading: isLoading, onLogin: () => context .read() .add(const AuthLoginRequested()), onForgotPassword: _openForgotPassword, biometricAvailable: _biometricAvailable, onBiometric: _authenticateBiometric, ), const SizedBox(height: 24), // Branding « Powered by Lions Dev » — fond toujours dark const PoweredByLionsDev(forceBrightness: Brightness.dark), ], ), ), ), ), ), ), ], ); }, ), ); } } // ───────────────────────────────────────────────────────────────────────────── // Widgets extraits // ───────────────────────────────────────────────────────────────────────────── class _GradientBackground extends StatelessWidget { const _GradientBackground(); @override Widget build(BuildContext context) { return const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [_kGradTop, _kGradMid, _kGradBot], stops: [0.0, 0.55, 1.0], ), ), child: SizedBox.expand(), ); } } class _LoginLogoSection extends StatelessWidget { const _LoginLogoSection(); @override Widget build(BuildContext context) { return Column( children: [ Image.asset( 'assets/images/unionflow-logo.png', width: 80, height: 80, fit: BoxFit.contain, ), 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, color: Colors.white.withOpacity(0.78), letterSpacing: 0.2, ), textAlign: TextAlign.center, ), ], ); } } class _LoginGlassCard extends StatelessWidget { const _LoginGlassCard({ required this.isLoading, required this.onLogin, required this.onForgotPassword, required this.biometricAvailable, required this.onBiometric, }); final bool isLoading; final VoidCallback onLogin; final VoidCallback onForgotPassword; final bool biometricAvailable; final VoidCallback onBiometric; @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; 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: AppColors.shadowStrong, 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), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: onForgotPassword, 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), if (isLoading) const Center( child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2.5), ) else ElevatedButton( onPressed: onLogin, style: ElevatedButton.styleFrom( backgroundColor: isDark ? AppColors.surfaceDark : AppColors.surface, foregroundColor: _kPrimaryBlue, 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: _kPrimaryBlue, letterSpacing: 0.2, ), ), ), if (biometricAvailable) ...[ const SizedBox(height: 14), Center( child: TextButton.icon( onPressed: onBiometric, 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 // ───────────────────────────────────────────────────────────────────────────── class _HexPatternOverlay extends StatelessWidget { const _HexPatternOverlay(); @override Widget build(BuildContext context) => 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)); i == 0 ? path.moveTo(p.dx, p.dy) : path.lineTo(p.dx, p.dy); } path.close(); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }