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.
This commit is contained in:
@@ -8,9 +8,17 @@ 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';
|
||||
|
||||
/// UnionFlow — Écran de connexion premium
|
||||
/// Gradient forêt + glassmorphism + animations + biométrie + remember me
|
||||
// ── 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});
|
||||
|
||||
@@ -19,43 +27,39 @@ class LoginPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
late final AnimationController _fadeController;
|
||||
late final AnimationController _slideController;
|
||||
late final AnimationController _fadeCtrl;
|
||||
late final AnimationController _slideCtrl;
|
||||
late final Animation<double> _fadeAnim;
|
||||
late final Animation<Offset> _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);
|
||||
_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: _slideController, curve: Curves.easeOutCubic));
|
||||
_fadeController.forward();
|
||||
_slideController.forward();
|
||||
.animate(CurvedAnimation(parent: _slideCtrl, curve: Curves.easeOutCubic));
|
||||
_fadeCtrl.forward();
|
||||
_slideCtrl.forward();
|
||||
_checkBiometrics();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeController.dispose();
|
||||
_slideController.dispose();
|
||||
_fadeCtrl.dispose();
|
||||
_slideCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkBiometrics() async {
|
||||
try {
|
||||
final canCheck = await _localAuth.canCheckBiometrics;
|
||||
final canCheck = await _localAuth.canCheckBiometrics;
|
||||
final supported = await _localAuth.isDeviceSupported();
|
||||
if (mounted) setState(() => _biometricAvailable = canCheck && supported);
|
||||
} catch (_) {}
|
||||
@@ -67,9 +71,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
localizedReason: 'Authentifiez-vous pour accéder à UnionFlow',
|
||||
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: false),
|
||||
);
|
||||
if (ok && mounted) {
|
||||
context.read<AuthBloc>().add(const AuthStatusChecked());
|
||||
}
|
||||
if (ok && mounted) context.read<AuthBloc>().add(const AuthStatusChecked());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -81,54 +83,41 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
'&response_type=code&scope=openid&kc_action=reset_credentials',
|
||||
);
|
||||
try {
|
||||
if (await canLaunchUrl(url)) await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onLogin() {
|
||||
context.read<AuthBloc>().add(const AuthLoginRequested());
|
||||
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: (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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
listener: _onAuthStateChanged,
|
||||
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 _GradientBackground(),
|
||||
const Positioned.fill(child: _HexPatternOverlay()),
|
||||
|
||||
// Content
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 28, vertical: 24),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: SlideTransition(
|
||||
@@ -136,9 +125,20 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildLogoSection(),
|
||||
const _LoginLogoSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildGlassCard(isLoading),
|
||||
_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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -152,13 +152,43 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLogoSection() {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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: [
|
||||
CustomPaint(
|
||||
size: const Size(48, 48),
|
||||
painter: _HexLogoMark(),
|
||||
Image.asset(
|
||||
'assets/images/unionflow-logo.png',
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
@@ -175,7 +205,6 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
'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,
|
||||
),
|
||||
@@ -184,19 +213,35 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildGlassCard(bool isLoading) {
|
||||
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,
|
||||
),
|
||||
color: Colors.white.withOpacity(0.22), width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.18),
|
||||
color: AppColors.shadowStrong,
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
@@ -225,12 +270,10 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Forgot password
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: _openForgotPassword,
|
||||
onPressed: onForgotPassword,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
@@ -249,43 +292,47 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
|
||||
// Biometric
|
||||
if (_biometricAvailable) ...[
|
||||
),
|
||||
),
|
||||
if (biometricAvailable) ...[
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: _authenticateBiometric,
|
||||
icon: const Icon(Icons.fingerprint_rounded, color: Colors.white60, size: 22),
|
||||
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: GoogleFonts.roboto(
|
||||
fontSize: 12.5, color: Colors.white60),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -300,14 +347,12 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
// 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());
|
||||
}
|
||||
Widget build(BuildContext context) =>
|
||||
CustomPaint(painter: _HexPatternPainter());
|
||||
}
|
||||
|
||||
class _HexPatternPainter extends CustomPainter {
|
||||
@@ -318,14 +363,17 @@ class _HexPatternPainter extends CustomPainter {
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.8;
|
||||
|
||||
const r = 22.0;
|
||||
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);
|
||||
for (double col = -1;
|
||||
col * hSpace - offset < size.width + hSpace;
|
||||
col++) {
|
||||
_hexagon(canvas, paint,
|
||||
Offset(col * hSpace + offset, row * vSpace), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,8 +382,9 @@ class _HexPatternPainter extends CustomPainter {
|
||||
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);
|
||||
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);
|
||||
@@ -344,57 +393,3 @@ class _HexPatternPainter extends CustomPainter {
|
||||
@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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user