Files
unionflow-mobile-apps/lib/features/authentication/presentation/pages/login_page.dart
dahoud ba779a7a40 feat(ui): dark mode adaptatif sur 15 pages/widgets restants
Pattern AppColors pair (isDark ternaries) appliqué sur :
- login_page : SnackBar error color Color(0xFFDC2626) → AppColors.error
  (gradient brand intentionnel non modifié)
- help_support : barre de recherche + ExpansionTile + chevrons → scheme adaptatif
- system_settings : état 'Accès réservé' + unselectedLabelColor TabBar
- epargne : date/description/boutons OutlinedButton foregroundColor adaptatifs
- conversation_tile, connected_recent_activities, connected_upcoming_events
- dashboard_notifications_widget
- budgets_list_page, pending_approvals_page, approve/reject_dialog
- create_organization_page, edit_organization_page, about_page

Les couleurs sémantiques (error, success, warning, primary) restent inchangées.
Les blancs/gradients intentionnels (AppBars brand, logos payment) préservés.
2026-04-15 20:14:59 +00:00

396 lines
13 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: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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
late final AnimationController _fadeCtrl;
late final AnimationController _slideCtrl;
late final Animation<double> _fadeAnim;
late final Animation<Offset> _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<Offset>(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<void> _checkBiometrics() async {
try {
final canCheck = await _localAuth.canCheckBiometrics;
final supported = await _localAuth.isDeviceSupported();
if (mounted) setState(() => _biometricAvailable = canCheck && supported);
} catch (_) {}
}
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) context.read<AuthBloc>().add(const AuthStatusChecked());
} 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 (_) {}
}
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<AuthBloc, AuthState>(
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<AuthBloc>()
.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;
}