568 lines
19 KiB
Dart
568 lines
19 KiB
Dart
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:shared_preferences/shared_preferences.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<LoginPage> createState() => _LoginPageState();
|
|
}
|
|
|
|
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();
|
|
|
|
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<Offset>(begin: const Offset(0, 0.12), end: Offset.zero)
|
|
.animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic));
|
|
_fadeController.forward();
|
|
_slideController.forward();
|
|
_checkBiometrics();
|
|
_loadSavedCredentials();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_fadeController.dispose();
|
|
_slideController.dispose();
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _checkBiometrics() async {
|
|
try {
|
|
final canCheck = await _localAuth.canCheckBiometrics;
|
|
final supported = await _localAuth.isDeviceSupported();
|
|
if (mounted) setState(() => _biometricAvailable = canCheck && supported);
|
|
} 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(
|
|
localizedReason: 'Authentifiez-vous pour accéder à UnionFlow',
|
|
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));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _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 (_) {}
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: BlocConsumer<AuthBloc, AuthState>(
|
|
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),
|
|
|
|
_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),
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// 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;
|
|
}
|