refactoring

This commit is contained in:
dahoud
2026-03-31 09:14:47 +00:00
parent 9bfffeeebe
commit 5383df6dcb
200 changed files with 11192 additions and 7063 deletions

View File

@@ -5,7 +5,11 @@ import '../../data/models/user.dart';
import '../../data/models/user_role.dart';
import '../../data/datasources/keycloak_auth_service.dart';
import '../../data/datasources/permission_engine.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/storage/dashboard_cache_manager.dart';
import '../../../../core/utils/logger.dart';
import '../../../../core/di/injection.dart';
import '../../../organizations/domain/repositories/organization_repository.dart';
// === ÉVÉNEMENTS ===
abstract class AuthEvent extends Equatable {
@@ -61,6 +65,30 @@ class AuthError extends AuthState {
List<Object?> get props => [message];
}
/// Compte bloqué (SUSPENDU ou DESACTIVE) — déconnexion + message.
class AuthAccountNotActive extends AuthState {
final String statutCompte;
final String message;
const AuthAccountNotActive({required this.statutCompte, required this.message});
@override
List<Object?> get props => [statutCompte, message];
}
/// Compte EN_ATTENTE_VALIDATION — l'OrgAdmin doit compléter l'onboarding.
/// On ne déconnecte PAS pour permettre les appels API de souscription.
class AuthPendingOnboarding extends AuthState {
final String onboardingState; // NO_SUBSCRIPTION | AWAITING_PAYMENT | PAYMENT_INITIATED | AWAITING_VALIDATION
final String? souscriptionId;
final String? organisationId;
const AuthPendingOnboarding({
required this.onboardingState,
this.souscriptionId,
this.organisationId,
});
@override
List<Object?> get props => [onboardingState, souscriptionId, organisationId];
}
// === BLOC ===
@lazySingleton
class AuthBloc extends Bloc<AuthEvent, AuthState> {
@@ -76,12 +104,40 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final user = await _authService.login(event.email, event.password);
if (user != null) {
final rawUser = await _authService.login(event.email, event.password);
if (rawUser != null) {
// Vérification du statut du compte UnionFlow (indépendant de Keycloak)
final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl);
if (status != null && status.isPendingOnboarding) {
// OrgAdmin en attente → rediriger vers l'onboarding (sans déconnecter)
final user = await _enrichUserWithOrgContext(rawUser);
final orgId = status.organisationId ??
(user.organizationContexts.isNotEmpty
? user.organizationContexts.first.organizationId
: null);
emit(AuthPendingOnboarding(
onboardingState: status.onboardingState,
souscriptionId: status.souscriptionId,
organisationId: orgId,
));
return;
}
if (status != null && status.isBlocked) {
await _authService.logout();
emit(AuthAccountNotActive(
statutCompte: status.statutCompte,
message: _messageForStatut(status.statutCompte),
));
return;
}
final user = await _enrichUserWithOrgContext(rawUser);
final permissions = await PermissionEngine.getEffectivePermissions(user);
final token = await _authService.getValidToken();
await DashboardCacheManager.invalidateForRole(user.primaryRole);
emit(AuthAuthenticated(
user: user,
effectiveRole: user.primaryRole,
@@ -110,11 +166,39 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
emit(AuthUnauthenticated());
return;
}
final user = await _authService.getCurrentUser();
if (user == null) {
final rawUser = await _authService.getCurrentUser();
if (rawUser == null) {
emit(AuthUnauthenticated());
return;
}
// Vérification du statut du compte (au redémarrage de l'app)
final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl);
if (status != null && status.isPendingOnboarding) {
final user = await _enrichUserWithOrgContext(rawUser);
final orgId = status.organisationId ??
(user.organizationContexts.isNotEmpty
? user.organizationContexts.first.organizationId
: null);
emit(AuthPendingOnboarding(
onboardingState: status.onboardingState,
souscriptionId: status.souscriptionId,
organisationId: orgId,
));
return;
}
if (status != null && status.isBlocked) {
await _authService.logout();
emit(AuthAccountNotActive(
statutCompte: status.statutCompte,
message: _messageForStatut(status.statutCompte),
));
return;
}
final user = await _enrichUserWithOrgContext(rawUser);
final permissions = await PermissionEngine.getEffectivePermissions(user);
final token = await _authService.getValidToken();
emit(AuthAuthenticated(
@@ -125,6 +209,51 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
));
}
/// Retourne un message lisible selon le statut du compte.
String _messageForStatut(String statut) {
switch (statut) {
case 'EN_ATTENTE_VALIDATION':
return 'Votre compte est en attente de validation par un administrateur. Vous serez notifié dès que votre accès sera activé.';
case 'SUSPENDU':
return 'Votre compte a été suspendu temporairement. Contactez votre administrateur pour plus d\'informations.';
case 'DESACTIVE':
return 'Votre compte a été désactivé. Contactez votre administrateur.';
default:
return 'Votre compte n\'est pas encore actif. Contactez votre administrateur.';
}
}
/// Enrichit le contexte organisationnel pour les AdminOrganisation.
///
/// Si le rôle est [UserRole.orgAdmin] et que [organizationContexts] est vide,
/// appelle GET /api/organisations/mes pour récupérer les organisations de l'admin.
Future<User> _enrichUserWithOrgContext(User user) async {
if (user.primaryRole != UserRole.orgAdmin ||
user.organizationContexts.isNotEmpty) {
return user;
}
try {
final orgRepo = getIt<IOrganizationRepository>();
final orgs = await orgRepo.getMesOrganisations();
if (orgs.isEmpty) return user;
final contexts = orgs
.where((o) => o.id != null && o.id!.isNotEmpty)
.map(
(o) => UserOrganizationContext(
organizationId: o.id!,
organizationName: o.nom,
role: UserRole.orgAdmin,
joinedAt: DateTime.now(),
),
)
.toList();
return contexts.isEmpty ? user : user.copyWith(organizationContexts: contexts);
} catch (e) {
AppLogger.warning('AuthBloc: impossible de charger le contexte org: $e');
return user;
}
}
Future<void> _onTokenRefreshRequested(AuthTokenRefreshRequested event, Emitter<AuthState> emit) async {
if (state is AuthAuthenticated) {
final newToken = await _authService.refreshToken();

View File

@@ -1,68 +1,133 @@
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';
import '../../../../shared/widgets/core_text_field.dart';
import '../../../../shared/widgets/dynamic_fab.dart';
import '../../../../shared/design_system/tokens/app_typography.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
/// UnionFlow Mobile - Écran de connexion (Mode DRY & Minimaliste)
/// UnionFlow Écran de connexion premium
/// Gradient forêt + glassmorphism + animations + biométrie + remember me
class LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
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> _openForgotPassword(BuildContext context) async {
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',
'&response_type=code&scope=openid&kc_action=reset_credentials',
);
try {
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible d\'ouvrir la page de réinitialisation')),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erreur lors de l\'ouverture du lien')),
);
}
}
if (await canLaunchUrl(url)) await launchUrl(url, mode: LaunchMode.externalApplication);
} catch (_) {}
}
void _onLogin() {
final email = _emailController.text;
Future<void> _onLogin() async {
final email = _emailController.text.trim();
final password = _passwordController.text;
if (email.isEmpty || password.isEmpty) return;
if (email.isNotEmpty && password.isNotEmpty) {
context.read<AuthBloc>().add(AuthLoginRequested(email, password));
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
@@ -70,100 +135,433 @@ class _LoginPageState extends State<LoginPage> {
return Scaffold(
body: BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
// Navigator 1.0 : Le BlocBuilder dans AppRouter gérera la transition vers MainNavigationLayout
} else if (state is AuthError) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message, style: AppTypography.bodyTextSmall),
backgroundColor: AppColors.error,
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 SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo minimaliste (Texte seul)
Center(
child: Text(
'UnionFlow',
style: AppTypography.headerSmall.copyWith(
fontSize: 24, // Exception unique pour le logo
color: AppColors.primaryGreen,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 8),
Center(
child: Text(
'Connexion à votre espace.',
style: AppTypography.subtitleSmall,
),
),
const SizedBox(height: 48),
// Champs de texte DRY
CoreTextField(
controller: _emailController,
hintText: 'Email ou Identifiant',
prefixIcon: Icons.person_outline,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
CoreTextField(
controller: _passwordController,
hintText: 'Mot de passe',
prefixIcon: Icons.lock_outline,
obscureText: true,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => _openForgotPassword(context),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Oublié ?',
style: AppTypography.subtitleSmall.copyWith(
color: AppColors.primaryGreen,
),
),
),
),
const SizedBox(height: 32),
// Bouton centralisé avec chargement intégré
Center(
child: isLoading
? const CircularProgressIndicator(color: AppColors.primaryGreen)
: DynamicFAB(
icon: Icons.arrow_forward,
label: 'Se Connecter',
onPressed: _onLogin,
),
),
],
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;
}