Authentification stable - WIP

This commit is contained in:
DahoudG
2025-09-19 12:35:46 +00:00
parent 63fe107f98
commit 098894bdc1
383 changed files with 13072 additions and 93334 deletions

View File

@@ -1,320 +0,0 @@
import 'package:flutter/material.dart';
import '../../shared/theme/app_theme.dart';
/// Bouton animé avec effets visuels sophistiqués
class AnimatedButton extends StatefulWidget {
final String text;
final IconData? icon;
final VoidCallback? onPressed;
final Color? backgroundColor;
final Color? foregroundColor;
final double? width;
final double? height;
final bool isLoading;
final AnimatedButtonStyle style;
const AnimatedButton({
super.key,
required this.text,
this.icon,
this.onPressed,
this.backgroundColor,
this.foregroundColor,
this.width,
this.height,
this.isLoading = false,
this.style = AnimatedButtonStyle.primary,
});
@override
State<AnimatedButton> createState() => _AnimatedButtonState();
}
class _AnimatedButtonState extends State<AnimatedButton>
with TickerProviderStateMixin {
late AnimationController _scaleController;
late AnimationController _shimmerController;
late AnimationController _loadingController;
late Animation<double> _scaleAnimation;
late Animation<double> _shimmerAnimation;
late Animation<double> _loadingAnimation;
bool _isPressed = false;
@override
void initState() {
super.initState();
_scaleController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_shimmerController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_loadingController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _scaleController,
curve: Curves.easeInOut,
));
_shimmerAnimation = Tween<double>(
begin: -1.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _shimmerController,
curve: Curves.easeInOut,
));
_loadingAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _loadingController,
curve: Curves.easeInOut,
));
if (widget.isLoading) {
_loadingController.repeat();
}
}
@override
void didUpdateWidget(AnimatedButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isLoading != oldWidget.isLoading) {
if (widget.isLoading) {
_loadingController.repeat();
} else {
_loadingController.stop();
_loadingController.reset();
}
}
}
@override
void dispose() {
_scaleController.dispose();
_shimmerController.dispose();
_loadingController.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
if (widget.onPressed != null && !widget.isLoading) {
setState(() => _isPressed = true);
_scaleController.forward();
}
}
void _onTapUp(TapUpDetails details) {
if (widget.onPressed != null && !widget.isLoading) {
setState(() => _isPressed = false);
_scaleController.reverse();
_shimmerController.forward().then((_) {
_shimmerController.reset();
});
}
}
void _onTapCancel() {
if (widget.onPressed != null && !widget.isLoading) {
setState(() => _isPressed = false);
_scaleController.reverse();
}
}
@override
Widget build(BuildContext context) {
final colors = _getColors();
return AnimatedBuilder(
animation: Listenable.merge([_scaleAnimation, _shimmerAnimation, _loadingAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onTap: widget.onPressed != null && !widget.isLoading ? widget.onPressed : null,
child: Container(
width: widget.width,
height: widget.height ?? 56,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colors.backgroundColor,
colors.backgroundColor.withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: colors.backgroundColor.withOpacity(0.3),
blurRadius: _isPressed ? 4 : 8,
offset: Offset(0, _isPressed ? 2 : 4),
),
],
),
child: Stack(
children: [
// Effet shimmer
if (!widget.isLoading)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_shimmerAnimation.value * 200, 0),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
Colors.white.withOpacity(0.2),
Colors.transparent,
],
stops: const [0.0, 0.5, 1.0],
),
),
),
);
},
),
),
),
// Contenu du bouton
Center(
child: widget.isLoading
? _buildLoadingContent(colors)
: _buildNormalContent(colors),
),
],
),
),
),
);
},
);
}
Widget _buildLoadingContent(_ButtonColors colors) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colors.foregroundColor),
),
),
const SizedBox(width: 12),
Text(
'Chargement...',
style: TextStyle(
color: colors.foregroundColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
);
}
Widget _buildNormalContent(_ButtonColors colors) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(
widget.icon,
color: colors.foregroundColor,
size: 20,
),
const SizedBox(width: 8),
],
Text(
widget.text,
style: TextStyle(
color: colors.foregroundColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
);
}
_ButtonColors _getColors() {
switch (widget.style) {
case AnimatedButtonStyle.primary:
return _ButtonColors(
backgroundColor: widget.backgroundColor ?? AppTheme.primaryColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
);
case AnimatedButtonStyle.secondary:
return _ButtonColors(
backgroundColor: widget.backgroundColor ?? AppTheme.secondaryColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
);
case AnimatedButtonStyle.success:
return _ButtonColors(
backgroundColor: widget.backgroundColor ?? AppTheme.successColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
);
case AnimatedButtonStyle.warning:
return _ButtonColors(
backgroundColor: widget.backgroundColor ?? AppTheme.warningColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
);
case AnimatedButtonStyle.error:
return _ButtonColors(
backgroundColor: widget.backgroundColor ?? AppTheme.errorColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
);
case AnimatedButtonStyle.outline:
return _ButtonColors(
backgroundColor: widget.backgroundColor ?? Colors.transparent,
foregroundColor: widget.foregroundColor ?? AppTheme.primaryColor,
);
}
}
}
class _ButtonColors {
final Color backgroundColor;
final Color foregroundColor;
_ButtonColors({
required this.backgroundColor,
required this.foregroundColor,
});
}
enum AnimatedButtonStyle {
primary,
secondary,
success,
warning,
error,
outline,
}

View File

@@ -1,352 +0,0 @@
import 'package:flutter/material.dart';
import '../../shared/theme/app_theme.dart';
/// Service de notifications animées
class AnimatedNotifications {
static OverlayEntry? _currentOverlay;
/// Affiche une notification de succès
static void showSuccess(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
}) {
_showNotification(
context,
message,
NotificationType.success,
duration,
);
}
/// Affiche une notification d'erreur
static void showError(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 4),
}) {
_showNotification(
context,
message,
NotificationType.error,
duration,
);
}
/// Affiche une notification d'information
static void showInfo(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
}) {
_showNotification(
context,
message,
NotificationType.info,
duration,
);
}
/// Affiche une notification d'avertissement
static void showWarning(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
}) {
_showNotification(
context,
message,
NotificationType.warning,
duration,
);
}
static void _showNotification(
BuildContext context,
String message,
NotificationType type,
Duration duration,
) {
// Supprimer la notification précédente si elle existe
_currentOverlay?.remove();
final overlay = Overlay.of(context);
late OverlayEntry overlayEntry;
overlayEntry = OverlayEntry(
builder: (context) => AnimatedNotificationWidget(
message: message,
type: type,
onDismiss: () {
overlayEntry.remove();
_currentOverlay = null;
},
),
);
_currentOverlay = overlayEntry;
overlay.insert(overlayEntry);
// Auto-dismiss après la durée spécifiée
Future.delayed(duration, () {
if (_currentOverlay == overlayEntry) {
overlayEntry.remove();
_currentOverlay = null;
}
});
}
/// Masque la notification actuelle
static void dismiss() {
_currentOverlay?.remove();
_currentOverlay = null;
}
}
/// Widget de notification animée
class AnimatedNotificationWidget extends StatefulWidget {
final String message;
final NotificationType type;
final VoidCallback onDismiss;
const AnimatedNotificationWidget({
super.key,
required this.message,
required this.type,
required this.onDismiss,
});
@override
State<AnimatedNotificationWidget> createState() => _AnimatedNotificationWidgetState();
}
class _AnimatedNotificationWidgetState extends State<AnimatedNotificationWidget>
with TickerProviderStateMixin {
late AnimationController _slideController;
late AnimationController _fadeController;
late AnimationController _scaleController;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_slideController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_fadeController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.elasticOut,
));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeOut,
));
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.05,
).animate(CurvedAnimation(
parent: _scaleController,
curve: Curves.easeInOut,
));
// Démarrer les animations d'entrée
_fadeController.forward();
_slideController.forward();
}
@override
void dispose() {
_slideController.dispose();
_fadeController.dispose();
_scaleController.dispose();
super.dispose();
}
void _dismiss() async {
await _fadeController.reverse();
widget.onDismiss();
}
@override
Widget build(BuildContext context) {
final colors = _getColors();
return Positioned(
top: MediaQuery.of(context).padding.top + 16,
left: 16,
right: 16,
child: AnimatedBuilder(
animation: Listenable.merge([
_slideAnimation,
_fadeAnimation,
_scaleAnimation,
]),
builder: (context, child) {
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Transform.scale(
scale: _scaleAnimation.value,
child: GestureDetector(
onTap: () => _scaleController.forward().then((_) {
_scaleController.reverse();
}),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colors.backgroundColor,
colors.backgroundColor.withOpacity(0.9),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: colors.backgroundColor.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
// Icône
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colors.iconBackgroundColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
colors.icon,
color: colors.iconColor,
size: 24,
),
),
const SizedBox(width: 12),
// Message
Expanded(
child: Text(
widget.message,
style: TextStyle(
color: colors.textColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
// Bouton de fermeture
GestureDetector(
onTap: _dismiss,
child: Container(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.close,
color: colors.textColor.withOpacity(0.7),
size: 20,
),
),
),
],
),
),
),
),
),
);
},
),
);
}
_NotificationColors _getColors() {
switch (widget.type) {
case NotificationType.success:
return _NotificationColors(
backgroundColor: AppTheme.successColor,
textColor: Colors.white,
icon: Icons.check_circle,
iconColor: Colors.white,
iconBackgroundColor: Colors.white.withOpacity(0.2),
);
case NotificationType.error:
return _NotificationColors(
backgroundColor: AppTheme.errorColor,
textColor: Colors.white,
icon: Icons.error,
iconColor: Colors.white,
iconBackgroundColor: Colors.white.withOpacity(0.2),
);
case NotificationType.warning:
return _NotificationColors(
backgroundColor: AppTheme.warningColor,
textColor: Colors.white,
icon: Icons.warning,
iconColor: Colors.white,
iconBackgroundColor: Colors.white.withOpacity(0.2),
);
case NotificationType.info:
return _NotificationColors(
backgroundColor: AppTheme.primaryColor,
textColor: Colors.white,
icon: Icons.info,
iconColor: Colors.white,
iconBackgroundColor: Colors.white.withOpacity(0.2),
);
}
}
}
class _NotificationColors {
final Color backgroundColor;
final Color textColor;
final IconData icon;
final Color iconColor;
final Color iconBackgroundColor;
_NotificationColors({
required this.backgroundColor,
required this.textColor,
required this.icon,
required this.iconColor,
required this.iconBackgroundColor,
});
}
enum NotificationType {
success,
error,
warning,
info,
}

View File

@@ -1,446 +0,0 @@
import 'package:flutter/material.dart';
import '../../shared/theme/app_theme.dart';
/// Animations de chargement personnalisées
class LoadingAnimations {
/// Indicateur de chargement avec points animés
static Widget dots({
Color color = AppTheme.primaryColor,
double size = 8.0,
Duration duration = const Duration(milliseconds: 1200),
}) {
return _DotsLoadingAnimation(
color: color,
size: size,
duration: duration,
);
}
/// Indicateur de chargement avec vagues
static Widget waves({
Color color = AppTheme.primaryColor,
double size = 40.0,
Duration duration = const Duration(milliseconds: 1000),
}) {
return _WavesLoadingAnimation(
color: color,
size: size,
duration: duration,
);
}
/// Indicateur de chargement avec rotation
static Widget spinner({
Color color = AppTheme.primaryColor,
double size = 40.0,
double strokeWidth = 4.0,
Duration duration = const Duration(milliseconds: 1000),
}) {
return _SpinnerLoadingAnimation(
color: color,
size: size,
strokeWidth: strokeWidth,
duration: duration,
);
}
/// Indicateur de chargement avec pulsation
static Widget pulse({
Color color = AppTheme.primaryColor,
double size = 40.0,
Duration duration = const Duration(milliseconds: 1000),
}) {
return _PulseLoadingAnimation(
color: color,
size: size,
duration: duration,
);
}
/// Skeleton loader pour les cartes
static Widget skeleton({
double height = 100.0,
double width = double.infinity,
BorderRadius? borderRadius,
Duration duration = const Duration(milliseconds: 1500),
}) {
return _SkeletonLoadingAnimation(
height: height,
width: width,
borderRadius: borderRadius ?? BorderRadius.circular(8),
duration: duration,
);
}
}
/// Animation de points qui rebondissent
class _DotsLoadingAnimation extends StatefulWidget {
final Color color;
final double size;
final Duration duration;
const _DotsLoadingAnimation({
required this.color,
required this.size,
required this.duration,
});
@override
State<_DotsLoadingAnimation> createState() => _DotsLoadingAnimationState();
}
class _DotsLoadingAnimationState extends State<_DotsLoadingAnimation>
with TickerProviderStateMixin {
late List<AnimationController> _controllers;
late List<Animation<double>> _animations;
@override
void initState() {
super.initState();
_controllers = List.generate(3, (index) {
return AnimationController(
duration: widget.duration,
vsync: this,
);
});
_animations = _controllers.map((controller) {
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: controller, curve: Curves.easeInOut),
);
}).toList();
_startAnimations();
}
void _startAnimations() {
for (int i = 0; i < _controllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 200), () {
if (mounted) {
_controllers[i].repeat(reverse: true);
}
});
}
}
@override
void dispose() {
for (final controller in _controllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _animations[index],
builder: (context, child) {
return Container(
margin: EdgeInsets.symmetric(horizontal: widget.size * 0.2),
child: Transform.translate(
offset: Offset(0, -widget.size * _animations[index].value),
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
),
);
},
);
}),
);
}
}
/// Animation de vagues
class _WavesLoadingAnimation extends StatefulWidget {
final Color color;
final double size;
final Duration duration;
const _WavesLoadingAnimation({
required this.color,
required this.size,
required this.duration,
});
@override
State<_WavesLoadingAnimation> createState() => _WavesLoadingAnimationState();
}
class _WavesLoadingAnimationState extends State<_WavesLoadingAnimation>
with TickerProviderStateMixin {
late List<AnimationController> _controllers;
late List<Animation<double>> _animations;
@override
void initState() {
super.initState();
_controllers = List.generate(4, (index) {
return AnimationController(
duration: widget.duration,
vsync: this,
);
});
_animations = _controllers.map((controller) {
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: controller, curve: Curves.easeInOut),
);
}).toList();
_startAnimations();
}
void _startAnimations() {
for (int i = 0; i < _controllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 150), () {
if (mounted) {
_controllers[i].repeat();
}
});
}
}
@override
void dispose() {
for (final controller in _controllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
alignment: Alignment.center,
children: List.generate(4, (index) {
return AnimatedBuilder(
animation: _animations[index],
builder: (context, child) {
return Container(
width: widget.size * _animations[index].value,
height: widget.size * _animations[index].value,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: widget.color.withOpacity(1 - _animations[index].value),
width: 2,
),
),
);
},
);
}),
),
);
}
}
/// Animation de spinner personnalisé
class _SpinnerLoadingAnimation extends StatefulWidget {
final Color color;
final double size;
final double strokeWidth;
final Duration duration;
const _SpinnerLoadingAnimation({
required this.color,
required this.size,
required this.strokeWidth,
required this.duration,
});
@override
State<_SpinnerLoadingAnimation> createState() => _SpinnerLoadingAnimationState();
}
class _SpinnerLoadingAnimationState extends State<_SpinnerLoadingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: SizedBox(
width: widget.size,
height: widget.size,
child: CircularProgressIndicator(
strokeWidth: widget.strokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(widget.color),
backgroundColor: widget.color.withOpacity(0.2),
),
),
);
},
);
}
}
/// Animation de pulsation
class _PulseLoadingAnimation extends StatefulWidget {
final Color color;
final double size;
final Duration duration;
const _PulseLoadingAnimation({
required this.color,
required this.size,
required this.duration,
});
@override
State<_PulseLoadingAnimation> createState() => _PulseLoadingAnimationState();
}
class _PulseLoadingAnimationState extends State<_PulseLoadingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(begin: 0.8, end: 1.2).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
);
},
);
}
}
/// Animation skeleton pour le chargement de contenu
class _SkeletonLoadingAnimation extends StatefulWidget {
final double height;
final double width;
final BorderRadius borderRadius;
final Duration duration;
const _SkeletonLoadingAnimation({
required this.height,
required this.width,
required this.borderRadius,
required this.duration,
});
@override
State<_SkeletonLoadingAnimation> createState() => _SkeletonLoadingAnimationState();
}
class _SkeletonLoadingAnimationState extends State<_SkeletonLoadingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
stops: [
(_animation.value - 0.3).clamp(0.0, 1.0),
_animation.value.clamp(0.0, 1.0),
(_animation.value + 0.3).clamp(0.0, 1.0),
],
colors: const [
Color(0xFFE0E0E0),
Color(0xFFF5F5F5),
Color(0xFFE0E0E0),
],
),
),
);
},
);
}
}

View File

@@ -1,368 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Widget avec micro-interactions pour les boutons
class InteractiveButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final Color? backgroundColor;
final Color? foregroundColor;
final EdgeInsetsGeometry? padding;
final BorderRadius? borderRadius;
final bool enableHapticFeedback;
final bool enableSoundFeedback;
final Duration animationDuration;
const InteractiveButton({
super.key,
required this.child,
this.onPressed,
this.backgroundColor,
this.foregroundColor,
this.padding,
this.borderRadius,
this.enableHapticFeedback = true,
this.enableSoundFeedback = false,
this.animationDuration = const Duration(milliseconds: 150),
});
@override
State<InteractiveButton> createState() => _InteractiveButtonState();
}
class _InteractiveButtonState extends State<InteractiveButton>
with TickerProviderStateMixin {
late AnimationController _scaleController;
late AnimationController _rippleController;
late Animation<double> _scaleAnimation;
late Animation<double> _rippleAnimation;
bool _isPressed = false;
@override
void initState() {
super.initState();
_scaleController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_rippleController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _scaleController,
curve: Curves.easeInOut,
));
_rippleAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _rippleController,
curve: Curves.easeOut,
));
}
@override
void dispose() {
_scaleController.dispose();
_rippleController.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
if (widget.onPressed != null) {
setState(() => _isPressed = true);
_scaleController.forward();
_rippleController.forward();
if (widget.enableHapticFeedback) {
HapticFeedback.lightImpact();
}
}
}
void _handleTapUp(TapUpDetails details) {
_handleTapEnd();
}
void _handleTapCancel() {
_handleTapEnd();
}
void _handleTapEnd() {
if (_isPressed) {
setState(() => _isPressed = false);
_scaleController.reverse();
Future.delayed(const Duration(milliseconds: 100), () {
_rippleController.reverse();
});
}
}
void _handleTap() {
if (widget.onPressed != null) {
if (widget.enableSoundFeedback) {
SystemSound.play(SystemSoundType.click);
}
widget.onPressed!();
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: _handleTap,
child: AnimatedBuilder(
animation: Listenable.merge([_scaleAnimation, _rippleAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
padding: widget.padding ?? const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
color: widget.backgroundColor ?? Theme.of(context).primaryColor,
borderRadius: widget.borderRadius ?? BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: (widget.backgroundColor ?? Theme.of(context).primaryColor)
.withOpacity(0.3),
blurRadius: _isPressed ? 8 : 12,
offset: Offset(0, _isPressed ? 2 : 4),
spreadRadius: _isPressed ? 0 : 2,
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
// Effet de ripple
if (_rippleAnimation.value > 0)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: widget.borderRadius ?? BorderRadius.circular(8),
color: Colors.white.withOpacity(
0.2 * _rippleAnimation.value,
),
),
),
),
// Contenu du bouton
DefaultTextStyle(
style: TextStyle(
color: widget.foregroundColor ?? Colors.white,
fontWeight: FontWeight.w600,
),
child: widget.child,
),
],
),
),
);
},
),
);
}
}
/// Widget avec effet de parallax pour les cartes
class ParallaxCard extends StatefulWidget {
final Widget child;
final double parallaxOffset;
final Duration animationDuration;
const ParallaxCard({
super.key,
required this.child,
this.parallaxOffset = 20.0,
this.animationDuration = const Duration(milliseconds: 200),
});
@override
State<ParallaxCard> createState() => _ParallaxCardState();
}
class _ParallaxCardState extends State<ParallaxCard>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _elevationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: Offset.zero,
end: Offset(0, -widget.parallaxOffset),
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_elevationAnimation = Tween<double>(
begin: 4.0,
end: 12.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => _controller.forward(),
onExit: (_) => _controller.reverse(),
child: GestureDetector(
onTapDown: (_) => _controller.forward(),
onTapUp: (_) => _controller.reverse(),
onTapCancel: () => _controller.reverse(),
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: _offsetAnimation.value,
child: Card(
elevation: _elevationAnimation.value,
child: widget.child,
),
);
},
),
),
);
}
}
/// Widget avec effet de morphing pour les icônes
class MorphingIcon extends StatefulWidget {
final IconData icon;
final IconData? alternateIcon;
final double size;
final Color? color;
final Duration animationDuration;
final VoidCallback? onPressed;
const MorphingIcon({
super.key,
required this.icon,
this.alternateIcon,
this.size = 24.0,
this.color,
this.animationDuration = const Duration(milliseconds: 300),
this.onPressed,
});
@override
State<MorphingIcon> createState() => _MorphingIconState();
}
class _MorphingIconState extends State<MorphingIcon>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
bool _isAlternate = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeIn),
));
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 0.5,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() {
_isAlternate = !_isAlternate;
});
_controller.reverse();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTap() {
if (widget.alternateIcon != null) {
_controller.forward();
}
widget.onPressed?.call();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _handleTap,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value == 0.0 ? 1.0 : _scaleAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value * 3.14159,
child: Icon(
_isAlternate && widget.alternateIcon != null
? widget.alternateIcon!
: widget.icon,
size: widget.size,
color: widget.color,
),
),
);
},
),
);
}
}

View File

@@ -1,375 +0,0 @@
import 'package:flutter/material.dart';
/// Transitions de pages personnalisées pour une meilleure UX
class PageTransitions {
/// Transition de glissement depuis la droite (par défaut iOS)
static PageRouteBuilder<T> slideFromRight<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
);
}
/// Transition de glissement depuis le bas
static PageRouteBuilder<T> slideFromBottom<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 350),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 1.0);
const end = Offset.zero;
const curve = Curves.easeOutCubic;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
);
}
/// Transition de fondu
static PageRouteBuilder<T> fadeIn<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 400),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
);
}
/// Transition d'échelle avec fondu
static PageRouteBuilder<T> scaleWithFade<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 400),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.easeInOutCubic;
var scaleTween = Tween(begin: 0.8, end: 1.0).chain(
CurveTween(curve: curve),
);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
return ScaleTransition(
scale: animation.drive(scaleTween),
child: FadeTransition(
opacity: animation.drive(fadeTween),
child: child,
),
);
},
);
}
/// Transition de rotation avec échelle
static PageRouteBuilder<T> rotateScale<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500),
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.elasticOut;
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var rotationTween = Tween(begin: 0.5, end: 1.0).chain(
CurveTween(curve: Curves.easeInOut),
);
return ScaleTransition(
scale: animation.drive(scaleTween),
child: RotationTransition(
turns: animation.drive(rotationTween),
child: child,
),
);
},
);
}
/// Transition personnalisée avec effet de rebond
static PageRouteBuilder<T> bounceIn<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 600),
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.bounceOut;
var scaleTween = Tween(begin: 0.3, end: 1.0).chain(
CurveTween(curve: curve),
);
return ScaleTransition(
scale: animation.drive(scaleTween),
child: child,
);
},
);
}
/// Transition de glissement avec parallaxe
static PageRouteBuilder<T> slideWithParallax<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 350),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const primaryBegin = Offset(1.0, 0.0);
const primaryEnd = Offset.zero;
const secondaryBegin = Offset.zero;
const secondaryEnd = Offset(-0.3, 0.0);
const curve = Curves.easeInOut;
var primaryTween = Tween(begin: primaryBegin, end: primaryEnd).chain(
CurveTween(curve: curve),
);
var secondaryTween = Tween(begin: secondaryBegin, end: secondaryEnd).chain(
CurveTween(curve: curve),
);
return Stack(
children: [
SlideTransition(
position: secondaryAnimation.drive(secondaryTween),
child: Container(), // Page précédente
),
SlideTransition(
position: animation.drive(primaryTween),
child: child,
),
],
);
},
);
}
/// Transition avec effet de morphing et blur
static PageRouteBuilder<T> morphWithBlur<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500),
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeInOutCubic,
);
final scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(curvedAnimation);
final fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: animation,
curve: const Interval(0.3, 1.0, curve: Curves.easeOut),
));
return FadeTransition(
opacity: fadeAnimation,
child: Transform.scale(
scale: scaleAnimation.value,
child: child,
),
);
},
);
}
/// Transition avec effet de rotation 3D
static PageRouteBuilder<T> rotate3D<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 600),
reverseTransitionDuration: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeInOutCubic,
);
return AnimatedBuilder(
animation: curvedAnimation,
builder: (context, child) {
final rotationY = (1.0 - curvedAnimation.value) * 0.5;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(rotationY),
child: child,
);
},
child: child,
);
},
);
}
}
/// Extensions pour faciliter l'utilisation des transitions
extension NavigatorTransitions on NavigatorState {
/// Navigation avec transition de glissement depuis la droite
Future<T?> pushSlideFromRight<T>(Widget page) {
return push<T>(PageTransitions.slideFromRight<T>(page));
}
/// Navigation avec transition de glissement depuis le bas
Future<T?> pushSlideFromBottom<T>(Widget page) {
return push<T>(PageTransitions.slideFromBottom<T>(page));
}
/// Navigation avec transition de fondu
Future<T?> pushFadeIn<T>(Widget page) {
return push<T>(PageTransitions.fadeIn<T>(page));
}
/// Navigation avec transition d'échelle et fondu
Future<T?> pushScaleWithFade<T>(Widget page) {
return push<T>(PageTransitions.scaleWithFade<T>(page));
}
/// Navigation avec transition de rebond
Future<T?> pushBounceIn<T>(Widget page) {
return push<T>(PageTransitions.bounceIn<T>(page));
}
/// Navigation avec transition de parallaxe
Future<T?> pushSlideWithParallax<T>(Widget page) {
return push<T>(PageTransitions.slideWithParallax<T>(page));
}
/// Navigation avec transition de morphing
Future<T?> pushMorphWithBlur<T>(Widget page) {
return push<T>(PageTransitions.morphWithBlur<T>(page));
}
/// Navigation avec transition de rotation 3D
Future<T?> pushRotate3D<T>(Widget page) {
return push<T>(PageTransitions.rotate3D<T>(page));
}
}
/// Widget d'animation pour les éléments de liste
class AnimatedListItem extends StatefulWidget {
final Widget child;
final int index;
final Duration delay;
final Duration duration;
final Curve curve;
final Offset slideOffset;
const AnimatedListItem({
super.key,
required this.child,
required this.index,
this.delay = const Duration(milliseconds: 100),
this.duration = const Duration(milliseconds: 500),
this.curve = Curves.easeOutCubic,
this.slideOffset = const Offset(0, 50),
});
@override
State<AnimatedListItem> createState() => _AnimatedListItemState();
}
class _AnimatedListItemState extends State<AnimatedListItem>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: widget.curve,
));
_slideAnimation = Tween<Offset>(
begin: widget.slideOffset,
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: widget.curve,
));
// Démarrer l'animation avec un délai basé sur l'index
Future.delayed(
Duration(milliseconds: widget.delay.inMilliseconds * widget.index),
() {
if (mounted) {
_controller.forward();
}
},
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: _slideAnimation.value,
child: Opacity(
opacity: _fadeAnimation.value,
child: widget.child,
),
);
},
);
}
}

View File

@@ -1,203 +1,460 @@
import 'dart:async';
/// BLoC d'authentification Keycloak adaptatif avec gestion des rôles
/// Support Keycloak avec contextes multi-organisations et états sophistiqués
library auth_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../models/auth_state.dart';
import '../services/auth_service.dart';
import '../services/auth_api_service.dart';
import 'auth_event.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import '../services/permission_engine.dart';
import '../services/keycloak_auth_service.dart';
import '../../cache/dashboard_cache_manager.dart';
/// BLoC pour gérer l'authentification
@singleton
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthService _authService;
late StreamSubscription<AuthState> _authStateSubscription;
// === ÉVÉNEMENTS ===
AuthBloc(this._authService) : super(const AuthState.unknown()) {
// Écouter les changements d'état du service
_authStateSubscription = _authService.authStateStream.listen(
(authState) => add(AuthStateChanged(authState)),
/// Événements d'authentification
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
/// Événement de connexion Keycloak
class AuthLoginRequested extends AuthEvent {
const AuthLoginRequested();
}
/// Événement de déconnexion
class AuthLogoutRequested extends AuthEvent {
const AuthLogoutRequested();
}
/// Événement de changement de contexte organisationnel
class AuthOrganizationContextChanged extends AuthEvent {
final String organizationId;
const AuthOrganizationContextChanged(this.organizationId);
@override
List<Object?> get props => [organizationId];
}
/// Événement de rafraîchissement du token
class AuthTokenRefreshRequested extends AuthEvent {
const AuthTokenRefreshRequested();
}
/// Événement de vérification de l'état d'authentification
class AuthStatusChecked extends AuthEvent {
const AuthStatusChecked();
}
/// Événement de mise à jour du profil utilisateur
class AuthUserProfileUpdated extends AuthEvent {
final User updatedUser;
const AuthUserProfileUpdated(this.updatedUser);
@override
List<Object?> get props => [updatedUser];
}
/// Événement de callback WebView
class AuthWebViewCallback extends AuthEvent {
final String callbackUrl;
const AuthWebViewCallback(this.callbackUrl);
@override
List<Object?> get props => [callbackUrl];
}
// === ÉTATS ===
/// États d'authentification
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
/// État initial
class AuthInitial extends AuthState {
const AuthInitial();
}
/// État de chargement
class AuthLoading extends AuthState {
const AuthLoading();
}
/// État authentifié avec contexte riche
class AuthAuthenticated extends AuthState {
final User user;
final String? currentOrganizationId;
final UserRole effectiveRole;
final List<String> effectivePermissions;
final DateTime authenticatedAt;
final String? accessToken;
const AuthAuthenticated({
required this.user,
this.currentOrganizationId,
required this.effectiveRole,
required this.effectivePermissions,
required this.authenticatedAt,
this.accessToken,
});
/// Vérifie si l'utilisateur a une permission
bool hasPermission(String permission) {
return effectivePermissions.contains(permission);
}
/// Vérifie si l'utilisateur peut effectuer une action
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
final permission = '$domain.$action.$scope';
return hasPermission(permission);
}
/// Obtient le contexte organisationnel actuel
UserOrganizationContext? get currentOrganizationContext {
if (currentOrganizationId == null) return null;
return user.getOrganizationContext(currentOrganizationId!);
}
/// Crée une copie avec des modifications
AuthAuthenticated copyWith({
User? user,
String? currentOrganizationId,
UserRole? effectiveRole,
List<String>? effectivePermissions,
DateTime? authenticatedAt,
String? accessToken,
}) {
return AuthAuthenticated(
user: user ?? this.user,
currentOrganizationId: currentOrganizationId ?? this.currentOrganizationId,
effectiveRole: effectiveRole ?? this.effectiveRole,
effectivePermissions: effectivePermissions ?? this.effectivePermissions,
authenticatedAt: authenticatedAt ?? this.authenticatedAt,
accessToken: accessToken ?? this.accessToken,
);
}
@override
List<Object?> get props => [
user,
currentOrganizationId,
effectiveRole,
effectivePermissions,
authenticatedAt,
accessToken,
];
}
// Gestionnaires d'événements
on<AuthInitializeRequested>(_onInitializeRequested);
/// État non authentifié
class AuthUnauthenticated extends AuthState {
final String? message;
const AuthUnauthenticated({this.message});
@override
List<Object?> get props => [message];
}
/// État d'erreur
class AuthError extends AuthState {
final String message;
final String? errorCode;
const AuthError({
required this.message,
this.errorCode,
});
@override
List<Object?> get props => [message, errorCode];
}
/// État indiquant qu'une WebView d'authentification est requise
class AuthWebViewRequired extends AuthState {
final String authUrl;
final String state;
final String codeVerifier;
const AuthWebViewRequired({
required this.authUrl,
required this.state,
required this.codeVerifier,
});
@override
List<Object?> get props => [authUrl, state, codeVerifier];
}
// === BLOC ===
/// BLoC d'authentification adaptatif
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(const AuthInitial()) {
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthOrganizationContextChanged>(_onOrganizationContextChanged);
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
on<AuthSessionExpired>(_onSessionExpired);
on<AuthStatusCheckRequested>(_onStatusCheckRequested);
on<AuthErrorCleared>(_onErrorCleared);
on<AuthStateChanged>(_onStateChanged);
on<AuthStatusChecked>(_onStatusChecked);
on<AuthUserProfileUpdated>(_onUserProfileUpdated);
on<AuthWebViewCallback>(_onWebViewCallback);
}
/// Initialisation de l'authentification
Future<void> _onInitializeRequested(
AuthInitializeRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthState.checking());
try {
await _authService.initialize();
} catch (e) {
emit(AuthState.error('Erreur d\'initialisation: $e'));
}
}
/// Gestion de la connexion
/// Gère la demande de connexion Keycloak via WebView
///
/// Cette méthode prépare l'authentification WebView et émet un état spécial
/// pour indiquer qu'une WebView doit être ouverte
Future<void> _onLoginRequested(
AuthLoginRequested event,
Emitter<AuthState> emit,
) async {
emit(state.copyWith(isLoading: true, errorMessage: null));
emit(const AuthLoading());
try {
await _authService.login(event.loginRequest);
// L'état sera mis à jour par le stream du service
} on AuthApiException catch (e) {
emit(state.copyWith(
isLoading: false,
errorMessage: e.message,
));
} catch (e) {
emit(state.copyWith(
isLoading: false,
errorMessage: 'Erreur de connexion: $e',
debugPrint('🔐 Préparation authentification Keycloak WebView...');
// Préparer l'authentification WebView
final Map<String, String> authParams = await KeycloakAuthService.prepareWebViewAuthentication();
debugPrint('✅ Authentification WebView préparée');
// Émettre un état spécial pour indiquer qu'une WebView doit être ouverte
debugPrint('🚀 Émission de l\'état AuthWebViewRequired...');
emit(AuthWebViewRequired(
authUrl: authParams['url']!,
state: authParams['state']!,
codeVerifier: authParams['code_verifier']!,
));
debugPrint('✅ État AuthWebViewRequired émis');
} catch (e, stackTrace) {
debugPrint('💥 Erreur préparation authentification Keycloak: $e');
debugPrint('Stack trace: $stackTrace');
emit(AuthError(message: 'Erreur de préparation: $e'));
}
}
/// Gestion de la déconnexion
/// Traite le callback WebView et finalise l'authentification
Future<void> _onWebViewCallback(
AuthWebViewCallback event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoading());
try {
debugPrint('🔄 Traitement callback WebView...');
// Traiter le callback et récupérer l'utilisateur
final User user = await KeycloakAuthService.handleWebViewCallback(event.callbackUrl);
debugPrint('👤 Utilisateur récupéré: ${user.fullName} (${user.primaryRole.displayName})');
// Calculer les permissions effectives
debugPrint('🔐 Calcul des permissions effectives...');
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
debugPrint('✅ Permissions effectives calculées: ${effectivePermissions.length} permissions');
// Invalider le cache pour forcer le rechargement
debugPrint('🧹 Invalidation du cache pour le rôle ${user.primaryRole.displayName}...');
await DashboardCacheManager.invalidateForRole(user.primaryRole);
debugPrint('✅ Cache invalidé');
emit(AuthAuthenticated(
user: user,
currentOrganizationId: null, // À implémenter selon vos besoins
effectiveRole: user.primaryRole,
effectivePermissions: effectivePermissions,
authenticatedAt: DateTime.now(),
accessToken: '', // Token géré par KeycloakWebViewAuthService
));
debugPrint('🎉 Authentification complète réussie');
} catch (e, stackTrace) {
debugPrint('💥 Erreur authentification: $e');
debugPrint('Stack trace: $stackTrace');
emit(AuthError(message: 'Erreur de connexion: $e'));
}
}
/// Gère la demande de déconnexion Keycloak
Future<void> _onLogoutRequested(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
emit(state.copyWith(isLoading: true));
emit(const AuthLoading());
try {
await _authService.logout();
// L'état sera mis à jour par le stream du service
} catch (e) {
// Même en cas d'erreur, on considère que la déconnexion locale a réussi
emit(const AuthState.unauthenticated());
debugPrint('🚪 Démarrage déconnexion Keycloak...');
// Déconnexion Keycloak
final logoutSuccess = await KeycloakAuthService.logout();
if (!logoutSuccess) {
debugPrint('⚠️ Déconnexion Keycloak partielle');
}
// Nettoyer le cache local
await DashboardCacheManager.clear();
// Invalider le cache des permissions
if (state is AuthAuthenticated) {
final authState = state as AuthAuthenticated;
PermissionEngine.invalidateUserCache(authState.user.id);
}
debugPrint('✅ Déconnexion complète réussie');
emit(const AuthUnauthenticated(message: 'Déconnexion réussie'));
} catch (e, stackTrace) {
debugPrint('💥 Erreur déconnexion: $e');
debugPrint('Stack trace: $stackTrace');
emit(AuthError(message: 'Erreur de déconnexion: $e'));
}
}
/// Gestion du rafraîchissement de token
/// Gère le changement de contexte organisationnel
Future<void> _onOrganizationContextChanged(
AuthOrganizationContextChanged event,
Emitter<AuthState> emit,
) async {
if (state is! AuthAuthenticated) return;
final currentState = state as AuthAuthenticated;
emit(const AuthLoading());
try {
// Recalculer le rôle effectif et les permissions
final effectiveRole = currentState.user.getRoleInOrganization(event.organizationId);
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
currentState.user,
organizationId: event.organizationId,
);
// Invalider le cache pour le nouveau contexte
PermissionEngine.invalidateUserCache(currentState.user.id);
emit(currentState.copyWith(
currentOrganizationId: event.organizationId,
effectiveRole: effectiveRole,
effectivePermissions: effectivePermissions,
));
} catch (e) {
emit(AuthError(message: 'Erreur de changement de contexte: $e'));
}
}
/// Gère le rafraîchissement du token
Future<void> _onTokenRefreshRequested(
AuthTokenRefreshRequested event,
Emitter<AuthState> emit,
) async {
// Le rafraîchissement est géré automatiquement par le service
// Cet événement peut être utilisé pour forcer un rafraîchissement manuel
if (state is! AuthAuthenticated) return;
final currentState = state as AuthAuthenticated;
try {
// Le service gère déjà le rafraîchissement automatique
// On peut ajouter ici une logique spécifique si nécessaire
// Simulation du rafraîchissement (à remplacer par l'API réelle)
await Future.delayed(const Duration(seconds: 1));
final newToken = 'refreshed_token_${DateTime.now().millisecondsSinceEpoch}';
emit(currentState.copyWith(accessToken: newToken));
} catch (e) {
emit(AuthState.error('Erreur lors du rafraîchissement: $e'));
emit(AuthError(message: 'Erreur de rafraîchissement: $e'));
}
}
/// Gestion de l'expiration de session
Future<void> _onSessionExpired(
AuthSessionExpired event,
/// Vérifie l'état d'authentification Keycloak
Future<void> _onStatusChecked(
AuthStatusChecked event,
Emitter<AuthState> emit,
) async {
emit(const AuthState.expired());
// Optionnel: essayer un rafraîchissement automatique
emit(const AuthLoading());
try {
await _authService.logout();
} catch (e) {
// Ignorer les erreurs de déconnexion lors de l'expiration
debugPrint('🔍 Vérification état authentification Keycloak...');
// Vérifier si l'utilisateur est authentifié avec Keycloak
final bool isAuthenticated = await KeycloakAuthService.isAuthenticated();
if (!isAuthenticated) {
debugPrint('❌ Utilisateur non authentifié');
emit(const AuthUnauthenticated());
return;
}
// Récupérer l'utilisateur actuel
final User? user = await KeycloakAuthService.getCurrentUser();
if (user == null) {
debugPrint('❌ Impossible de récupérer l\'utilisateur');
emit(const AuthUnauthenticated());
return;
}
// Calculer les permissions effectives
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
// Récupérer le token d'accès
final String? accessToken = await KeycloakAuthService.getAccessToken();
debugPrint('✅ Utilisateur authentifié: ${user.fullName}');
emit(AuthAuthenticated(
user: user,
currentOrganizationId: null, // À implémenter selon vos besoins
effectiveRole: user.primaryRole,
effectivePermissions: effectivePermissions,
authenticatedAt: DateTime.now(),
accessToken: accessToken ?? '',
));
} catch (e, stackTrace) {
debugPrint('💥 Erreur vérification authentification: $e');
debugPrint('Stack trace: $stackTrace');
emit(AuthError(message: 'Erreur de vérification: $e'));
}
}
/// Vérification du statut d'authentification
Future<void> _onStatusCheckRequested(
AuthStatusCheckRequested event,
/// Met à jour le profil utilisateur
Future<void> _onUserProfileUpdated(
AuthUserProfileUpdated event,
Emitter<AuthState> emit,
) async {
// Utiliser l'état actuel du service
final currentServiceState = _authService.currentState;
if (currentServiceState != state) {
emit(currentServiceState);
}
}
/// Nettoyage des erreurs
void _onErrorCleared(
AuthErrorCleared event,
Emitter<AuthState> emit,
) {
if (state.errorMessage != null) {
emit(state.copyWith(errorMessage: null));
}
}
/// Mise à jour depuis le service d'authentification
void _onStateChanged(
AuthStateChanged event,
Emitter<AuthState> emit,
) {
final newState = event.authState as AuthState;
if (state is! AuthAuthenticated) return;
// Émettre le nouvel état seulement s'il a changé
if (newState != state) {
emit(newState);
}
}
/// Vérifie si l'utilisateur est connecté
bool get isAuthenticated => state.isAuthenticated;
/// Récupère l'utilisateur actuel
get currentUser => state.user;
/// Vérifie si l'utilisateur a un rôle spécifique
bool hasRole(String role) {
return _authService.hasRole(role);
}
/// Vérifie si l'utilisateur a un des rôles spécifiés
bool hasAnyRole(List<String> roles) {
return _authService.hasAnyRole(roles);
}
/// Vérifie si la session expire bientôt
bool get isSessionExpiringSoon => state.isExpiringSoon;
/// Récupère le message d'erreur formaté
String? get errorMessage {
final error = state.errorMessage;
if (error == null) return null;
// Formatage des messages d'erreur pour l'utilisateur
if (error.contains('network') || error.contains('connexion')) {
return 'Problème de connexion. Vérifiez votre réseau.';
}
final currentState = state as AuthAuthenticated;
if (error.contains('401') || error.contains('Identifiants')) {
return 'Email ou mot de passe incorrect.';
try {
// Recalculer les permissions si nécessaire
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
event.updatedUser,
organizationId: currentState.currentOrganizationId,
);
emit(currentState.copyWith(
user: event.updatedUser,
effectivePermissions: effectivePermissions,
));
} catch (e) {
emit(AuthError(message: 'Erreur de mise à jour: $e'));
}
if (error.contains('403')) {
return 'Accès non autorisé.';
}
if (error.contains('timeout')) {
return 'Délai d\'attente dépassé. Réessayez.';
}
if (error.contains('server') || error.contains('500')) {
return 'Erreur serveur temporaire. Réessayez plus tard.';
}
return error;
}
@override
Future<void> close() {
_authStateSubscription.cancel();
return super.close();
}
}
}

View File

@@ -1,60 +0,0 @@
import 'package:equatable/equatable.dart';
import '../models/login_request.dart';
/// Événements d'authentification
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
/// Initialiser l'authentification
class AuthInitializeRequested extends AuthEvent {
const AuthInitializeRequested();
}
/// Demande de connexion
class AuthLoginRequested extends AuthEvent {
final LoginRequest loginRequest;
const AuthLoginRequested(this.loginRequest);
@override
List<Object> get props => [loginRequest];
}
/// Demande de déconnexion
class AuthLogoutRequested extends AuthEvent {
const AuthLogoutRequested();
}
/// Demande de rafraîchissement de token
class AuthTokenRefreshRequested extends AuthEvent {
const AuthTokenRefreshRequested();
}
/// Session expirée
class AuthSessionExpired extends AuthEvent {
const AuthSessionExpired();
}
/// Vérification de l'état d'authentification
class AuthStatusCheckRequested extends AuthEvent {
const AuthStatusCheckRequested();
}
/// Réinitialisation de l'erreur
class AuthErrorCleared extends AuthEvent {
const AuthErrorCleared();
}
/// Changement d'état depuis le service
class AuthStateChanged extends AuthEvent {
final dynamic authState;
const AuthStateChanged(this.authState);
@override
List<Object?> get props => [authState];
}

View File

@@ -1,74 +0,0 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/auth_state.dart';
import '../services/temp_auth_service.dart';
import 'auth_event.dart';
/// BLoC temporaire pour test sans injection de dépendances
class TempAuthBloc extends Bloc<AuthEvent, AuthState> {
final TempAuthService _authService;
late StreamSubscription<AuthState> _authStateSubscription;
TempAuthBloc(this._authService) : super(const AuthState.unknown()) {
_authStateSubscription = _authService.authStateStream.listen(
(authState) => add(AuthStateChanged(authState)),
);
on<AuthInitializeRequested>(_onInitializeRequested);
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthErrorCleared>(_onErrorCleared);
on<AuthStateChanged>(_onStateChanged);
}
Future<void> _onInitializeRequested(
AuthInitializeRequested event,
Emitter<AuthState> emit,
) async {
await _authService.initialize();
}
Future<void> _onLoginRequested(
AuthLoginRequested event,
Emitter<AuthState> emit,
) async {
try {
await _authService.login(event.loginRequest);
} catch (e) {
emit(AuthState.error(e.toString()));
}
}
Future<void> _onLogoutRequested(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
await _authService.logout();
}
void _onErrorCleared(
AuthErrorCleared event,
Emitter<AuthState> emit,
) {
if (state.errorMessage != null) {
emit(state.copyWith(errorMessage: null));
}
}
void _onStateChanged(
AuthStateChanged event,
Emitter<AuthState> emit,
) {
final newState = event.authState as AuthState;
if (newState != state) {
emit(newState);
}
}
@override
Future<void> close() {
_authStateSubscription.cancel();
_authService.dispose();
return super.close();
}
}

View File

@@ -1,143 +0,0 @@
import 'package:equatable/equatable.dart';
import 'user_info.dart';
/// États d'authentification possibles
enum AuthStatus {
unknown, // État initial
checking, // Vérification en cours
authenticated,// Utilisateur connecté
unauthenticated, // Utilisateur non connecté
expired, // Session expirée
error, // Erreur d'authentification
}
/// État d'authentification de l'application
class AuthState extends Equatable {
final AuthStatus status;
final UserInfo? user;
final String? accessToken;
final String? refreshToken;
final DateTime? expiresAt;
final String? errorMessage;
final bool isLoading;
const AuthState({
this.status = AuthStatus.unknown,
this.user,
this.accessToken,
this.refreshToken,
this.expiresAt,
this.errorMessage,
this.isLoading = false,
});
/// État initial inconnu
const AuthState.unknown() : this(status: AuthStatus.unknown);
/// État de vérification
const AuthState.checking() : this(
status: AuthStatus.checking,
isLoading: true,
);
/// État authentifié
const AuthState.authenticated({
required UserInfo user,
required String accessToken,
required String refreshToken,
required DateTime expiresAt,
}) : this(
status: AuthStatus.authenticated,
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
isLoading: false,
);
/// État non authentifié
const AuthState.unauthenticated({String? errorMessage}) : this(
status: AuthStatus.unauthenticated,
errorMessage: errorMessage,
isLoading: false,
);
/// État de session expirée
const AuthState.expired() : this(
status: AuthStatus.expired,
isLoading: false,
);
/// État d'erreur
const AuthState.error(String errorMessage) : this(
status: AuthStatus.error,
errorMessage: errorMessage,
isLoading: false,
);
/// Vérifie si l'utilisateur est connecté
bool get isAuthenticated => status == AuthStatus.authenticated;
/// Vérifie si l'authentification est en cours de vérification
bool get isChecking => status == AuthStatus.checking;
/// Vérifie si la session est valide
bool get isSessionValid {
if (!isAuthenticated || expiresAt == null) return false;
return DateTime.now().isBefore(expiresAt!);
}
/// Vérifie si la session expire bientôt
bool get isExpiringSoon {
if (!isAuthenticated || expiresAt == null) return false;
final threshold = DateTime.now().add(const Duration(minutes: 5));
return expiresAt!.isBefore(threshold);
}
/// Crée une copie avec des modifications
AuthState copyWith({
AuthStatus? status,
UserInfo? user,
String? accessToken,
String? refreshToken,
DateTime? expiresAt,
String? errorMessage,
bool? isLoading,
}) {
return AuthState(
status: status ?? this.status,
user: user ?? this.user,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
expiresAt: expiresAt ?? this.expiresAt,
errorMessage: errorMessage ?? this.errorMessage,
isLoading: isLoading ?? this.isLoading,
);
}
/// Crée une copie en effaçant les données sensibles
AuthState clearSensitiveData() {
return AuthState(
status: status,
user: user,
errorMessage: errorMessage,
isLoading: isLoading,
);
}
@override
List<Object?> get props => [
status,
user,
accessToken,
refreshToken,
expiresAt,
errorMessage,
isLoading,
];
@override
String toString() {
return 'AuthState(status: $status, user: ${user?.email}, isLoading: $isLoading, error: $errorMessage)';
}
}

View File

@@ -1,50 +0,0 @@
import 'package:equatable/equatable.dart';
/// Modèle de requête de connexion
class LoginRequest extends Equatable {
final String email;
final String password;
final bool rememberMe;
const LoginRequest({
required this.email,
required this.password,
this.rememberMe = false,
});
Map<String, dynamic> toJson() {
return {
'email': email,
'password': password,
'rememberMe': rememberMe,
};
}
factory LoginRequest.fromJson(Map<String, dynamic> json) {
return LoginRequest(
email: json['email'] ?? '',
password: json['password'] ?? '',
rememberMe: json['rememberMe'] ?? false,
);
}
LoginRequest copyWith({
String? email,
String? password,
bool? rememberMe,
}) {
return LoginRequest(
email: email ?? this.email,
password: password ?? this.password,
rememberMe: rememberMe ?? this.rememberMe,
);
}
@override
List<Object?> get props => [email, password, rememberMe];
@override
String toString() {
return 'LoginRequest(email: $email, rememberMe: $rememberMe)';
}
}

View File

@@ -1,96 +0,0 @@
import 'package:equatable/equatable.dart';
import 'user_info.dart';
/// Modèle de réponse de connexion
class LoginResponse extends Equatable {
final String accessToken;
final String refreshToken;
final String tokenType;
final DateTime expiresAt;
final DateTime refreshExpiresAt;
final UserInfo user;
const LoginResponse({
required this.accessToken,
required this.refreshToken,
required this.tokenType,
required this.expiresAt,
required this.refreshExpiresAt,
required this.user,
});
/// Vérifie si le token d'accès est expiré
bool get isAccessTokenExpired {
return DateTime.now().isAfter(expiresAt);
}
/// Vérifie si le refresh token est expiré
bool get isRefreshTokenExpired {
return DateTime.now().isAfter(refreshExpiresAt);
}
/// Vérifie si le token expire dans les prochaines minutes
bool isExpiringSoon({int minutes = 5}) {
final threshold = DateTime.now().add(Duration(minutes: minutes));
return expiresAt.isBefore(threshold);
}
factory LoginResponse.fromJson(Map<String, dynamic> json) {
return LoginResponse(
accessToken: json['accessToken'] ?? '',
refreshToken: json['refreshToken'] ?? '',
tokenType: json['tokenType'] ?? 'Bearer',
expiresAt: json['expiresAt'] != null
? DateTime.parse(json['expiresAt'])
: DateTime.now().add(const Duration(minutes: 15)),
refreshExpiresAt: json['refreshExpiresAt'] != null
? DateTime.parse(json['refreshExpiresAt'])
: DateTime.now().add(const Duration(days: 7)),
user: UserInfo.fromJson(json['user'] ?? {}),
);
}
Map<String, dynamic> toJson() {
return {
'accessToken': accessToken,
'refreshToken': refreshToken,
'tokenType': tokenType,
'expiresAt': expiresAt.toIso8601String(),
'refreshExpiresAt': refreshExpiresAt.toIso8601String(),
'user': user.toJson(),
};
}
LoginResponse copyWith({
String? accessToken,
String? refreshToken,
String? tokenType,
DateTime? expiresAt,
DateTime? refreshExpiresAt,
UserInfo? user,
}) {
return LoginResponse(
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
tokenType: tokenType ?? this.tokenType,
expiresAt: expiresAt ?? this.expiresAt,
refreshExpiresAt: refreshExpiresAt ?? this.refreshExpiresAt,
user: user ?? this.user,
);
}
@override
List<Object?> get props => [
accessToken,
refreshToken,
tokenType,
expiresAt,
refreshExpiresAt,
user,
];
@override
String toString() {
return 'LoginResponse(tokenType: $tokenType, user: ${user.email}, expiresAt: $expiresAt)';
}
}

View File

@@ -1,5 +0,0 @@
// Export all auth models
export 'auth_state.dart';
export 'login_request.dart';
export 'login_response.dart';
export 'user_info.dart';

View File

@@ -0,0 +1,212 @@
/// Système de permissions granulaires ultra-sophistiqué
/// Plus de 50 permissions atomiques avec héritage intelligent
library permission_matrix;
/// Matrice de permissions atomiques pour contrôle granulaire
///
/// Chaque permission suit la convention : `domain.action.scope`
/// Exemples : `members.edit.own`, `finances.view.all`, `system.admin.global`
class PermissionMatrix {
// === PERMISSIONS SYSTÈME ===
static const String SYSTEM_ADMIN = 'system.admin.global';
static const String SYSTEM_CONFIG = 'system.config.global';
static const String SYSTEM_MONITORING = 'system.monitoring.view';
static const String SYSTEM_BACKUP = 'system.backup.manage';
static const String SYSTEM_SECURITY = 'system.security.manage';
static const String SYSTEM_AUDIT = 'system.audit.view';
static const String SYSTEM_LOGS = 'system.logs.view';
static const String SYSTEM_MAINTENANCE = 'system.maintenance.execute';
// === PERMISSIONS ORGANISATION ===
static const String ORG_CREATE = 'organization.create.global';
static const String ORG_DELETE = 'organization.delete.own';
static const String ORG_CONFIG = 'organization.config.own';
static const String ORG_BRANDING = 'organization.branding.manage';
static const String ORG_SETTINGS = 'organization.settings.manage';
static const String ORG_PERMISSIONS = 'organization.permissions.manage';
static const String ORG_WORKFLOWS = 'organization.workflows.manage';
static const String ORG_INTEGRATIONS = 'organization.integrations.manage';
// === PERMISSIONS DASHBOARD ===
static const String DASHBOARD_VIEW = 'dashboard.view.own';
static const String DASHBOARD_ADMIN = 'dashboard.admin.view';
static const String DASHBOARD_ANALYTICS = 'dashboard.analytics.view';
static const String DASHBOARD_REPORTS = 'dashboard.reports.generate';
static const String DASHBOARD_EXPORT = 'dashboard.export.data';
static const String DASHBOARD_CUSTOMIZE = 'dashboard.customize.layout';
// === PERMISSIONS MEMBRES ===
static const String MEMBERS_VIEW_ALL = 'members.view.all';
static const String MEMBERS_VIEW_OWN = 'members.view.own';
static const String MEMBERS_CREATE = 'members.create.organization';
static const String MEMBERS_EDIT_ALL = 'members.edit.all';
static const String MEMBERS_EDIT_OWN = 'members.edit.own';
static const String MEMBERS_EDIT_BASIC = 'members.edit.basic';
static const String MEMBERS_DELETE = 'members.delete.organization';
static const String MEMBERS_DELETE_ALL = 'members.delete.all';
static const String MEMBERS_APPROVE = 'members.approve.requests';
static const String MEMBERS_SUSPEND = 'members.suspend.organization';
static const String MEMBERS_EXPORT = 'members.export.data';
static const String MEMBERS_IMPORT = 'members.import.data';
static const String MEMBERS_COMMUNICATE = 'members.communicate.all';
// === PERMISSIONS FINANCES ===
static const String FINANCES_VIEW_ALL = 'finances.view.all';
static const String FINANCES_VIEW_OWN = 'finances.view.own';
static const String FINANCES_EDIT_ALL = 'finances.edit.all';
static const String FINANCES_MANAGE = 'finances.manage.organization';
static const String FINANCES_APPROVE = 'finances.approve.transactions';
static const String FINANCES_REPORTS = 'finances.reports.generate';
static const String FINANCES_BUDGET = 'finances.budget.manage';
static const String FINANCES_AUDIT = 'finances.audit.access';
// === PERMISSIONS ÉVÉNEMENTS ===
static const String EVENTS_VIEW_ALL = 'events.view.all';
static const String EVENTS_VIEW_PUBLIC = 'events.view.public';
static const String EVENTS_CREATE = 'events.create.organization';
static const String EVENTS_EDIT_ALL = 'events.edit.all';
static const String EVENTS_EDIT_OWN = 'events.edit.own';
static const String EVENTS_DELETE = 'events.delete.organization';
static const String EVENTS_PARTICIPATE = 'events.participate.public';
static const String EVENTS_MODERATE = 'events.moderate.organization';
static const String EVENTS_ANALYTICS = 'events.analytics.view';
// === PERMISSIONS SOLIDARITÉ ===
static const String SOLIDARITY_VIEW_ALL = 'solidarity.view.all';
static const String SOLIDARITY_VIEW_OWN = 'solidarity.view.own';
static const String SOLIDARITY_VIEW_PUBLIC = 'solidarity.view.public';
static const String SOLIDARITY_CREATE = 'solidarity.create.request';
static const String SOLIDARITY_EDIT_ALL = 'solidarity.edit.all';
static const String SOLIDARITY_APPROVE = 'solidarity.approve.requests';
static const String SOLIDARITY_PARTICIPATE = 'solidarity.participate.actions';
static const String SOLIDARITY_MANAGE = 'solidarity.manage.organization';
static const String SOLIDARITY_FUND = 'solidarity.fund.manage';
// === PERMISSIONS COMMUNICATION ===
static const String COMM_SEND_ALL = 'communication.send.all';
static const String COMM_SEND_MEMBERS = 'communication.send.members';
static const String COMM_MODERATE = 'communication.moderate.organization';
static const String COMM_BROADCAST = 'communication.broadcast.organization';
static const String COMM_TEMPLATES = 'communication.templates.manage';
// === PERMISSIONS RAPPORTS ===
static const String REPORTS_VIEW_ALL = 'reports.view.all';
static const String REPORTS_GENERATE = 'reports.generate.organization';
static const String REPORTS_EXPORT = 'reports.export.data';
static const String REPORTS_SCHEDULE = 'reports.schedule.automated';
// === PERMISSIONS MODÉRATION ===
static const String MODERATION_CONTENT = 'moderation.content.manage';
static const String MODERATION_USERS = 'moderation.users.manage';
static const String MODERATION_REPORTS = 'moderation.reports.handle';
/// Toutes les permissions disponibles dans le système
static const List<String> ALL_PERMISSIONS = [
// Système
SYSTEM_ADMIN, SYSTEM_CONFIG, SYSTEM_MONITORING, SYSTEM_BACKUP,
SYSTEM_SECURITY, SYSTEM_AUDIT, SYSTEM_LOGS, SYSTEM_MAINTENANCE,
// Organisation
ORG_CREATE, ORG_DELETE, ORG_CONFIG, ORG_BRANDING, ORG_SETTINGS,
ORG_PERMISSIONS, ORG_WORKFLOWS, ORG_INTEGRATIONS,
// Dashboard
DASHBOARD_VIEW, DASHBOARD_ADMIN, DASHBOARD_ANALYTICS, DASHBOARD_REPORTS,
DASHBOARD_EXPORT, DASHBOARD_CUSTOMIZE,
// Membres
MEMBERS_VIEW_ALL, MEMBERS_VIEW_OWN, MEMBERS_CREATE, MEMBERS_EDIT_ALL,
MEMBERS_EDIT_OWN, MEMBERS_DELETE, MEMBERS_APPROVE, MEMBERS_SUSPEND,
MEMBERS_EXPORT, MEMBERS_IMPORT, MEMBERS_COMMUNICATE,
// Finances
FINANCES_VIEW_ALL, FINANCES_VIEW_OWN, FINANCES_MANAGE, FINANCES_APPROVE,
FINANCES_REPORTS, FINANCES_BUDGET, FINANCES_AUDIT,
// Événements
EVENTS_VIEW_ALL, EVENTS_VIEW_PUBLIC, EVENTS_CREATE, EVENTS_EDIT_ALL,
EVENTS_EDIT_OWN, EVENTS_DELETE, EVENTS_MODERATE, EVENTS_ANALYTICS,
// Solidarité
SOLIDARITY_VIEW_ALL, SOLIDARITY_VIEW_OWN, SOLIDARITY_CREATE,
SOLIDARITY_APPROVE, SOLIDARITY_MANAGE, SOLIDARITY_FUND,
// Communication
COMM_SEND_ALL, COMM_SEND_MEMBERS, COMM_MODERATE, COMM_BROADCAST,
COMM_TEMPLATES,
// Rapports
REPORTS_VIEW_ALL, REPORTS_GENERATE, REPORTS_EXPORT, REPORTS_SCHEDULE,
// Modération
MODERATION_CONTENT, MODERATION_USERS, MODERATION_REPORTS,
];
/// Permissions publiques (accessibles sans authentification)
static const List<String> PUBLIC_PERMISSIONS = [
EVENTS_VIEW_PUBLIC,
];
/// Vérifie si une permission est publique
static bool isPublicPermission(String permission) {
return PUBLIC_PERMISSIONS.contains(permission);
}
/// Obtient le domaine d'une permission (partie avant le premier point)
static String getDomain(String permission) {
return permission.split('.').first;
}
/// Obtient l'action d'une permission (partie du milieu)
static String getAction(String permission) {
final parts = permission.split('.');
return parts.length > 1 ? parts[1] : '';
}
/// Obtient la portée d'une permission (partie après le dernier point)
static String getScope(String permission) {
return permission.split('.').last;
}
/// Vérifie si une permission implique une autre (héritage)
static bool implies(String higherPermission, String lowerPermission) {
// Exemple : 'members.edit.all' implique 'members.view.all'
final higherParts = higherPermission.split('.');
final lowerParts = lowerPermission.split('.');
if (higherParts.length != 3 || lowerParts.length != 3) return false;
// Même domaine requis
if (higherParts[0] != lowerParts[0]) return false;
// Vérification des implications d'actions
return _actionImplies(higherParts[1], lowerParts[1]) &&
_scopeImplies(higherParts[2], lowerParts[2]);
}
/// Vérifie si une action implique une autre
static bool _actionImplies(String higherAction, String lowerAction) {
const actionHierarchy = {
'admin': ['manage', 'edit', 'create', 'delete', 'view'],
'manage': ['edit', 'create', 'delete', 'view'],
'edit': ['view'],
'create': ['view'],
'delete': ['view'],
};
return actionHierarchy[higherAction]?.contains(lowerAction) ??
higherAction == lowerAction;
}
/// Vérifie si une portée implique une autre
static bool _scopeImplies(String higherScope, String lowerScope) {
const scopeHierarchy = {
'global': ['all', 'organization', 'own'],
'all': ['organization', 'own'],
'organization': ['own'],
};
return scopeHierarchy[higherScope]?.contains(lowerScope) ??
higherScope == lowerScope;
}
}

View File

@@ -0,0 +1,360 @@
/// Modèles de données utilisateur avec contexte et permissions
/// Support des relations multi-organisations et permissions contextuelles
library user_models;
import 'package:equatable/equatable.dart';
import 'user_role.dart';
import 'permission_matrix.dart';
/// Modèle utilisateur principal avec contexte multi-organisations
///
/// Supporte les utilisateurs ayant des rôles différents dans plusieurs organisations
/// avec des permissions contextuelles et des préférences personnalisées
class User extends Equatable {
/// Identifiant unique de l'utilisateur
final String id;
/// Informations personnelles
final String email;
final String firstName;
final String lastName;
final String? avatar;
final String? phone;
/// Rôle principal de l'utilisateur (le plus élevé)
final UserRole primaryRole;
/// Contextes organisationnels (rôles dans différentes organisations)
final List<UserOrganizationContext> organizationContexts;
/// Permissions supplémentaires accordées spécifiquement
final List<String> additionalPermissions;
/// Permissions révoquées spécifiquement
final List<String> revokedPermissions;
/// Préférences utilisateur
final UserPreferences preferences;
/// Métadonnées
final DateTime createdAt;
final DateTime lastLoginAt;
final bool isActive;
final bool isVerified;
/// Constructeur du modèle utilisateur
const User({
required this.id,
required this.email,
required this.firstName,
required this.lastName,
required this.primaryRole,
this.avatar,
this.phone,
this.organizationContexts = const [],
this.additionalPermissions = const [],
this.revokedPermissions = const [],
this.preferences = const UserPreferences(),
required this.createdAt,
required this.lastLoginAt,
this.isActive = true,
this.isVerified = false,
});
/// Nom complet de l'utilisateur
String get fullName => '$firstName $lastName';
/// Initiales de l'utilisateur
String get initials => '${firstName[0]}${lastName[0]}'.toUpperCase();
/// Vérifie si l'utilisateur a une permission dans le contexte actuel
bool hasPermission(String permission, {String? organizationId}) {
// Vérification des permissions révoquées
if (revokedPermissions.contains(permission)) return false;
// Vérification des permissions additionnelles
if (additionalPermissions.contains(permission)) return true;
// Vérification du rôle principal
if (primaryRole.hasPermission(permission)) return true;
// Vérification dans le contexte organisationnel spécifique
if (organizationId != null) {
final context = getOrganizationContext(organizationId);
if (context?.role.hasPermission(permission) == true) return true;
}
// Vérification dans tous les contextes organisationnels
return organizationContexts.any((context) =>
context.role.hasPermission(permission));
}
/// Obtient le contexte organisationnel pour une organisation
UserOrganizationContext? getOrganizationContext(String organizationId) {
try {
return organizationContexts.firstWhere(
(context) => context.organizationId == organizationId,
);
} catch (e) {
return null;
}
}
/// Obtient le rôle dans une organisation spécifique
UserRole getRoleInOrganization(String organizationId) {
final context = getOrganizationContext(organizationId);
return context?.role ?? primaryRole;
}
/// Vérifie si l'utilisateur est membre d'une organisation
bool isMemberOfOrganization(String organizationId) {
return organizationContexts.any(
(context) => context.organizationId == organizationId,
);
}
/// Obtient toutes les permissions effectives de l'utilisateur
List<String> getEffectivePermissions({String? organizationId}) {
final permissions = <String>{};
// Permissions du rôle principal
permissions.addAll(primaryRole.getEffectivePermissions());
// Permissions des contextes organisationnels
if (organizationId != null) {
final context = getOrganizationContext(organizationId);
if (context != null) {
permissions.addAll(context.role.getEffectivePermissions());
}
} else {
for (final context in organizationContexts) {
permissions.addAll(context.role.getEffectivePermissions());
}
}
// Permissions additionnelles
permissions.addAll(additionalPermissions);
// Retirer les permissions révoquées
permissions.removeAll(revokedPermissions);
return permissions.toList()..sort();
}
/// Crée une copie de l'utilisateur avec des modifications
User copyWith({
String? email,
String? firstName,
String? lastName,
String? avatar,
String? phone,
UserRole? primaryRole,
List<UserOrganizationContext>? organizationContexts,
List<String>? additionalPermissions,
List<String>? revokedPermissions,
UserPreferences? preferences,
DateTime? lastLoginAt,
bool? isActive,
bool? isVerified,
}) {
return User(
id: id,
email: email ?? this.email,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
avatar: avatar ?? this.avatar,
phone: phone ?? this.phone,
primaryRole: primaryRole ?? this.primaryRole,
organizationContexts: organizationContexts ?? this.organizationContexts,
additionalPermissions: additionalPermissions ?? this.additionalPermissions,
revokedPermissions: revokedPermissions ?? this.revokedPermissions,
preferences: preferences ?? this.preferences,
createdAt: createdAt,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
isActive: isActive ?? this.isActive,
isVerified: isVerified ?? this.isVerified,
);
}
/// Conversion vers Map pour sérialisation
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'firstName': firstName,
'lastName': lastName,
'avatar': avatar,
'phone': phone,
'primaryRole': primaryRole.name,
'organizationContexts': organizationContexts.map((c) => c.toJson()).toList(),
'additionalPermissions': additionalPermissions,
'revokedPermissions': revokedPermissions,
'preferences': preferences.toJson(),
'createdAt': createdAt.toIso8601String(),
'lastLoginAt': lastLoginAt.toIso8601String(),
'isActive': isActive,
'isVerified': isVerified,
};
}
/// Création depuis Map pour désérialisation
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
email: json['email'],
firstName: json['firstName'],
lastName: json['lastName'],
avatar: json['avatar'],
phone: json['phone'],
primaryRole: UserRole.fromString(json['primaryRole']) ?? UserRole.visitor,
organizationContexts: (json['organizationContexts'] as List?)
?.map((c) => UserOrganizationContext.fromJson(c))
.toList() ?? [],
additionalPermissions: List<String>.from(json['additionalPermissions'] ?? []),
revokedPermissions: List<String>.from(json['revokedPermissions'] ?? []),
preferences: UserPreferences.fromJson(json['preferences'] ?? {}),
createdAt: DateTime.parse(json['createdAt']),
lastLoginAt: DateTime.parse(json['lastLoginAt']),
isActive: json['isActive'] ?? true,
isVerified: json['isVerified'] ?? false,
);
}
@override
List<Object?> get props => [
id, email, firstName, lastName, avatar, phone, primaryRole,
organizationContexts, additionalPermissions, revokedPermissions,
preferences, createdAt, lastLoginAt, isActive, isVerified,
];
}
/// Contexte organisationnel d'un utilisateur
///
/// Définit le rôle et les permissions spécifiques dans une organisation
class UserOrganizationContext extends Equatable {
/// Identifiant de l'organisation
final String organizationId;
/// Nom de l'organisation
final String organizationName;
/// Rôle de l'utilisateur dans cette organisation
final UserRole role;
/// Permissions spécifiques dans cette organisation
final List<String> specificPermissions;
/// Date d'adhésion à l'organisation
final DateTime joinedAt;
/// Statut dans l'organisation
final bool isActive;
/// Constructeur du contexte organisationnel
const UserOrganizationContext({
required this.organizationId,
required this.organizationName,
required this.role,
this.specificPermissions = const [],
required this.joinedAt,
this.isActive = true,
});
/// Conversion vers Map
Map<String, dynamic> toJson() {
return {
'organizationId': organizationId,
'organizationName': organizationName,
'role': role.name,
'specificPermissions': specificPermissions,
'joinedAt': joinedAt.toIso8601String(),
'isActive': isActive,
};
}
/// Création depuis Map
factory UserOrganizationContext.fromJson(Map<String, dynamic> json) {
return UserOrganizationContext(
organizationId: json['organizationId'],
organizationName: json['organizationName'],
role: UserRole.fromString(json['role']) ?? UserRole.visitor,
specificPermissions: List<String>.from(json['specificPermissions'] ?? []),
joinedAt: DateTime.parse(json['joinedAt']),
isActive: json['isActive'] ?? true,
);
}
@override
List<Object?> get props => [
organizationId, organizationName, role, specificPermissions, joinedAt, isActive,
];
}
/// Préférences utilisateur personnalisables
class UserPreferences extends Equatable {
/// Langue préférée
final String language;
/// Thème préféré
final String theme;
/// Notifications activées
final bool notificationsEnabled;
/// Notifications par email
final bool emailNotifications;
/// Notifications push
final bool pushNotifications;
/// Layout du dashboard préféré
final String dashboardLayout;
/// Timezone
final String timezone;
/// Constructeur des préférences
const UserPreferences({
this.language = 'fr',
this.theme = 'system',
this.notificationsEnabled = true,
this.emailNotifications = true,
this.pushNotifications = true,
this.dashboardLayout = 'default',
this.timezone = 'Europe/Paris',
});
/// Conversion vers Map
Map<String, dynamic> toJson() {
return {
'language': language,
'theme': theme,
'notificationsEnabled': notificationsEnabled,
'emailNotifications': emailNotifications,
'pushNotifications': pushNotifications,
'dashboardLayout': dashboardLayout,
'timezone': timezone,
};
}
/// Création depuis Map
factory UserPreferences.fromJson(Map<String, dynamic> json) {
return UserPreferences(
language: json['language'] ?? 'fr',
theme: json['theme'] ?? 'system',
notificationsEnabled: json['notificationsEnabled'] ?? true,
emailNotifications: json['emailNotifications'] ?? true,
pushNotifications: json['pushNotifications'] ?? true,
dashboardLayout: json['dashboardLayout'] ?? 'default',
timezone: json['timezone'] ?? 'Europe/Paris',
);
}
@override
List<Object?> get props => [
language, theme, notificationsEnabled, emailNotifications,
pushNotifications, dashboardLayout, timezone,
];
}

View File

@@ -1,97 +0,0 @@
import 'package:equatable/equatable.dart';
/// Modèle des informations utilisateur
class UserInfo extends Equatable {
final String id;
final String email;
final String firstName;
final String lastName;
final String role;
final List<String>? roles;
final String? profilePicture;
final bool isActive;
const UserInfo({
required this.id,
required this.email,
required this.firstName,
required this.lastName,
required this.role,
this.roles,
this.profilePicture,
required this.isActive,
});
String get fullName => '$firstName $lastName';
String get initials {
final f = firstName.isNotEmpty ? firstName[0] : '';
final l = lastName.isNotEmpty ? lastName[0] : '';
return '$f$l'.toUpperCase();
}
factory UserInfo.fromJson(Map<String, dynamic> json) {
return UserInfo(
id: json['id'] ?? '',
email: json['email'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
role: json['role'] ?? 'membre',
roles: json['roles'] != null ? List<String>.from(json['roles']) : null,
profilePicture: json['profilePicture'],
isActive: json['isActive'] ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'firstName': firstName,
'lastName': lastName,
'role': role,
'roles': roles,
'profilePicture': profilePicture,
'isActive': isActive,
};
}
UserInfo copyWith({
String? id,
String? email,
String? firstName,
String? lastName,
String? role,
List<String>? roles,
String? profilePicture,
bool? isActive,
}) {
return UserInfo(
id: id ?? this.id,
email: email ?? this.email,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
role: role ?? this.role,
roles: roles ?? this.roles,
profilePicture: profilePicture ?? this.profilePicture,
isActive: isActive ?? this.isActive,
);
}
@override
List<Object?> get props => [
id,
email,
firstName,
lastName,
role,
roles,
profilePicture,
isActive,
];
@override
String toString() {
return 'UserInfo(id: $id, email: $email, fullName: $fullName, role: $role, isActive: $isActive)';
}
}

View File

@@ -0,0 +1,319 @@
/// Système de rôles utilisateurs avec hiérarchie intelligente
/// 6 niveaux de rôles avec permissions héritées et contextuelles
library user_role;
import 'permission_matrix.dart';
/// Énumération des rôles utilisateurs avec hiérarchie et permissions
///
/// Chaque rôle a un niveau numérique pour faciliter les comparaisons
/// et une liste de permissions spécifiques avec héritage intelligent
enum UserRole {
/// Super Administrateur - Niveau système (100)
/// Accès complet à toutes les fonctionnalités multi-organisations
superAdmin(
level: 100,
displayName: 'Super Administrateur',
description: 'Accès complet système et multi-organisations',
color: 0xFF6C5CE7, // Violet sophistiqué
permissions: _superAdminPermissions,
),
/// Administrateur d'Organisation - Niveau organisation (80)
/// Gestion complète de son organisation uniquement
orgAdmin(
level: 80,
displayName: 'Administrateur',
description: 'Gestion complète de l\'organisation',
color: 0xFF0984E3, // Bleu corporate
permissions: _orgAdminPermissions,
),
/// Modérateur/Gestionnaire - Niveau intermédiaire (60)
/// Gestion partielle selon permissions accordées
moderator(
level: 60,
displayName: 'Modérateur',
description: 'Gestion partielle et modération',
color: 0xFFE17055, // Orange focus
permissions: _moderatorPermissions,
),
/// Membre Actif - Niveau utilisateur (40)
/// Accès aux fonctionnalités membres avec participation active
activeMember(
level: 40,
displayName: 'Membre Actif',
description: 'Participation active aux activités',
color: 0xFF00B894, // Vert communauté
permissions: _activeMemberPermissions,
),
/// Membre Simple - Niveau basique (20)
/// Accès limité aux informations personnelles
simpleMember(
level: 20,
displayName: 'Membre',
description: 'Accès aux informations de base',
color: 0xFF00CEC9, // Teal simple
permissions: _simpleMemberPermissions,
),
/// Visiteur/Invité - Niveau public (0)
/// Accès aux informations publiques uniquement
visitor(
level: 0,
displayName: 'Visiteur',
description: 'Accès aux informations publiques',
color: 0xFF6C5CE7, // Indigo accueillant
permissions: _visitorPermissions,
);
/// Constructeur du rôle avec toutes ses propriétés
const UserRole({
required this.level,
required this.displayName,
required this.description,
required this.color,
required this.permissions,
});
/// Niveau numérique du rôle (0-100)
final int level;
/// Nom d'affichage du rôle
final String displayName;
/// Description détaillée du rôle
final String description;
/// Couleur thématique du rôle (format 0xFFRRGGBB)
final int color;
/// Liste des permissions spécifiques au rôle
final List<String> permissions;
/// Vérifie si ce rôle a un niveau supérieur ou égal à un autre
bool hasLevelOrAbove(UserRole other) => level >= other.level;
/// Vérifie si ce rôle a un niveau strictement supérieur à un autre
bool hasLevelAbove(UserRole other) => level > other.level;
/// Vérifie si ce rôle possède une permission spécifique
bool hasPermission(String permission) {
// Vérification directe
if (permissions.contains(permission)) return true;
// Vérification par héritage (permissions impliquées)
return permissions.any((p) => PermissionMatrix.implies(p, permission));
}
/// Obtient toutes les permissions effectives (directes + héritées)
List<String> getEffectivePermissions() {
final effective = <String>{};
// Ajouter les permissions directes
effective.addAll(permissions);
// Ajouter les permissions impliquées
for (final permission in permissions) {
for (final allPermission in PermissionMatrix.ALL_PERMISSIONS) {
if (PermissionMatrix.implies(permission, allPermission)) {
effective.add(allPermission);
}
}
}
return effective.toList()..sort();
}
/// Vérifie si ce rôle peut effectuer une action sur un domaine
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
final permission = '$domain.$action.$scope';
return hasPermission(permission);
}
/// Obtient le rôle à partir de son nom
static UserRole? fromString(String roleName) {
return UserRole.values.firstWhere(
(role) => role.name == roleName,
orElse: () => UserRole.visitor,
);
}
/// Obtient tous les rôles avec un niveau inférieur ou égal
List<UserRole> getSubordinateRoles() {
return UserRole.values.where((role) => role.level < level).toList();
}
/// Obtient tous les rôles avec un niveau supérieur ou égal
List<UserRole> getSuperiorRoles() {
return UserRole.values.where((role) => role.level >= level).toList();
}
}
// === DÉFINITIONS DES PERMISSIONS PAR RÔLE ===
/// Permissions du Super Administrateur (accès complet)
const List<String> _superAdminPermissions = [
// Toutes les permissions système
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_MONITORING,
PermissionMatrix.SYSTEM_BACKUP,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.SYSTEM_AUDIT,
PermissionMatrix.SYSTEM_LOGS,
PermissionMatrix.SYSTEM_MAINTENANCE,
// Gestion globale des organisations
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
// Accès complet aux dashboards
PermissionMatrix.DASHBOARD_ADMIN,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.DASHBOARD_REPORTS,
PermissionMatrix.DASHBOARD_EXPORT,
// Gestion complète des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE,
PermissionMatrix.MEMBERS_EXPORT,
PermissionMatrix.MEMBERS_IMPORT,
// Accès complet aux finances
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_MANAGE,
PermissionMatrix.FINANCES_AUDIT,
// Tous les rapports
PermissionMatrix.REPORTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.REPORTS_EXPORT,
PermissionMatrix.REPORTS_SCHEDULE,
];
/// Permissions de l'Administrateur d'Organisation
const List<String> _orgAdminPermissions = [
// Configuration organisation
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.ORG_BRANDING,
PermissionMatrix.ORG_SETTINGS,
PermissionMatrix.ORG_PERMISSIONS,
PermissionMatrix.ORG_WORKFLOWS,
// Dashboard organisation
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.DASHBOARD_REPORTS,
PermissionMatrix.DASHBOARD_CUSTOMIZE,
// Gestion des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_CREATE,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.MEMBERS_SUSPEND,
PermissionMatrix.MEMBERS_COMMUNICATE,
// Gestion financière
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_MANAGE,
PermissionMatrix.FINANCES_REPORTS,
PermissionMatrix.FINANCES_BUDGET,
// Gestion des événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.EVENTS_DELETE,
PermissionMatrix.EVENTS_ANALYTICS,
// Gestion de la solidarité
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_APPROVE,
PermissionMatrix.SOLIDARITY_MANAGE,
PermissionMatrix.SOLIDARITY_FUND,
// Communication
PermissionMatrix.COMM_SEND_ALL,
PermissionMatrix.COMM_BROADCAST,
PermissionMatrix.COMM_TEMPLATES,
// Rapports organisation
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.REPORTS_EXPORT,
];
/// Permissions du Modérateur
const List<String> _moderatorPermissions = [
// Dashboard limité
PermissionMatrix.DASHBOARD_VIEW,
// Modération des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.MODERATION_USERS,
// Modération du contenu
PermissionMatrix.MODERATION_CONTENT,
PermissionMatrix.MODERATION_REPORTS,
// Événements limités
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_MODERATE,
// Communication modérée
PermissionMatrix.COMM_MODERATE,
PermissionMatrix.COMM_SEND_MEMBERS,
];
/// Permissions du Membre Actif
const List<String> _activeMemberPermissions = [
// Dashboard personnel
PermissionMatrix.DASHBOARD_VIEW,
// Profil personnel
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
// Finances personnelles
PermissionMatrix.FINANCES_VIEW_OWN,
// Événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.EVENTS_EDIT_OWN,
// Solidarité
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_CREATE,
];
/// Permissions du Membre Simple
const List<String> _simpleMemberPermissions = [
// Dashboard basique
PermissionMatrix.DASHBOARD_VIEW,
// Profil personnel uniquement
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
// Finances personnelles
PermissionMatrix.FINANCES_VIEW_OWN,
// Événements publics
PermissionMatrix.EVENTS_VIEW_PUBLIC,
// Solidarité consultation
PermissionMatrix.SOLIDARITY_VIEW_OWN,
];
/// Permissions du Visiteur
const List<String> _visitorPermissions = [
// Événements publics uniquement
PermissionMatrix.EVENTS_VIEW_PUBLIC,
];

View File

@@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import '../../../features/auth/presentation/pages/keycloak_login_page.dart';
import '../../../features/navigation/presentation/pages/main_navigation.dart';
import '../services/keycloak_webview_auth_service.dart';
import '../models/auth_state.dart';
import '../../di/injection.dart';
/// Wrapper qui gère l'authentification et le routage
class AuthWrapper extends StatefulWidget {
const AuthWrapper({super.key});
@override
State<AuthWrapper> createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
late KeycloakWebViewAuthService _authService;
@override
void initState() {
super.initState();
_authService = getIt<KeycloakWebViewAuthService>();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<AuthState>(
stream: _authService.authStateStream,
initialData: _authService.currentState,
builder: (context, snapshot) {
final authState = snapshot.data ?? const AuthState.unknown();
// Affichage de l'écran de chargement pendant la vérification
if (authState.isChecking) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Vérification de l\'authentification...'),
],
),
),
);
}
// Si l'utilisateur est authentifié, afficher l'application principale
if (authState.isAuthenticated) {
return const MainNavigation();
}
// Sinon, afficher la page de connexion
return const KeycloakLoginPage();
},
);
}
}

View File

@@ -1,306 +0,0 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../core/network/dio_client.dart';
import '../models/login_request.dart';
import '../models/login_response.dart';
/// Service API pour l'authentification
@singleton
class AuthApiService {
final DioClient _dioClient;
late final Dio _dio;
AuthApiService(this._dioClient) {
_dio = _dioClient.dio;
}
/// Effectue la connexion utilisateur
Future<LoginResponse> login(LoginRequest request) async {
try {
final response = await _dio.post(
'/api/auth/login',
data: request.toJson(),
options: Options(
headers: {
'Content-Type': 'application/json',
},
// Désactiver l'interceptor d'auth pour cette requête
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return LoginResponse.fromJson(response.data);
} else {
throw AuthApiException(
'Erreur de connexion',
statusCode: response.statusCode,
response: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue lors de la connexion: $e');
}
}
/// Rafraîchit le token d'accès
Future<LoginResponse> refreshToken(String refreshToken) async {
try {
final response = await _dio.post(
'/api/auth/refresh',
data: {'refreshToken': refreshToken},
options: Options(
headers: {
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return LoginResponse.fromJson(response.data);
} else {
throw AuthApiException(
'Erreur lors du rafraîchissement du token',
statusCode: response.statusCode,
response: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue lors du rafraîchissement: $e');
}
}
/// Effectue la déconnexion
Future<void> logout(String? refreshToken) async {
try {
await _dio.post(
'/api/auth/logout',
data: refreshToken != null ? {'refreshToken': refreshToken} : {},
options: Options(
headers: {
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
} on DioException catch (e) {
// Ignorer les erreurs de déconnexion côté serveur
// La déconnexion locale est plus importante
print('Erreur lors de la déconnexion serveur: ${e.message}');
} catch (e) {
print('Erreur inattendue lors de la déconnexion: $e');
}
}
/// Valide un token côté serveur
Future<bool> validateToken(String accessToken) async {
try {
final response = await _dio.get(
'/api/auth/validate',
options: Options(
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
return response.statusCode == 200;
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
return false;
}
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur lors de la validation du token: $e');
}
}
/// Récupère les informations de l'API d'authentification
Future<Map<String, dynamic>> getAuthInfo() async {
try {
final response = await _dio.get(
'/api/auth/info',
options: Options(
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
} else {
throw AuthApiException(
'Erreur lors de la récupération des informations',
statusCode: response.statusCode,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue: $e');
}
}
/// Gestion centralisée des erreurs Dio
AuthApiException _handleDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return AuthApiException(
'Délai d\'attente dépassé. Vérifiez votre connexion internet.',
type: AuthErrorType.timeout,
);
case DioExceptionType.connectionError:
return AuthApiException(
'Impossible de se connecter au serveur. Vérifiez votre connexion internet.',
type: AuthErrorType.network,
);
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
final data = e.response?.data;
switch (statusCode) {
case 400:
return AuthApiException(
_extractErrorMessage(data) ?? 'Données de requête invalides',
statusCode: statusCode,
type: AuthErrorType.validation,
response: data,
);
case 401:
return AuthApiException(
_extractErrorMessage(data) ?? 'Identifiants invalides',
statusCode: statusCode,
type: AuthErrorType.unauthorized,
response: data,
);
case 403:
return AuthApiException(
_extractErrorMessage(data) ?? 'Accès interdit',
statusCode: statusCode,
type: AuthErrorType.forbidden,
response: data,
);
case 429:
return AuthApiException(
'Trop de tentatives. Veuillez réessayer plus tard.',
statusCode: statusCode,
type: AuthErrorType.rateLimited,
response: data,
);
case 500:
case 502:
case 503:
case 504:
return AuthApiException(
'Erreur serveur temporaire. Veuillez réessayer.',
statusCode: statusCode,
type: AuthErrorType.server,
response: data,
);
default:
return AuthApiException(
_extractErrorMessage(data) ?? 'Erreur serveur inconnue',
statusCode: statusCode,
response: data,
);
}
case DioExceptionType.cancel:
return AuthApiException(
'Requête annulée',
type: AuthErrorType.cancelled,
);
default:
return AuthApiException(
'Erreur réseau: ${e.message}',
type: AuthErrorType.unknown,
);
}
}
/// Extrait le message d'erreur de la réponse
String? _extractErrorMessage(dynamic data) {
if (data == null) return null;
if (data is Map<String, dynamic>) {
return data['message'] ?? data['error'] ?? data['detail'];
}
if (data is String) {
try {
final json = jsonDecode(data) as Map<String, dynamic>;
return json['message'] ?? json['error'] ?? json['detail'];
} catch (_) {
return data;
}
}
return null;
}
}
/// Types d'erreurs d'authentification
enum AuthErrorType {
validation,
unauthorized,
forbidden,
timeout,
network,
server,
rateLimited,
cancelled,
unknown,
}
/// Exception spécifique à l'API d'authentification
class AuthApiException implements Exception {
final String message;
final int? statusCode;
final AuthErrorType type;
final dynamic response;
const AuthApiException(
this.message, {
this.statusCode,
this.type = AuthErrorType.unknown,
this.response,
});
bool get isNetworkError =>
type == AuthErrorType.network ||
type == AuthErrorType.timeout;
bool get isServerError => type == AuthErrorType.server;
bool get isClientError =>
type == AuthErrorType.validation ||
type == AuthErrorType.unauthorized ||
type == AuthErrorType.forbidden;
bool get isRetryable =>
isNetworkError ||
isServerError ||
type == AuthErrorType.rateLimited;
@override
String toString() {
return 'AuthApiException: $message ${statusCode != null ? '(Status: $statusCode)' : ''}';
}
}

View File

@@ -1,318 +0,0 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/user_info.dart';
import '../storage/secure_token_storage.dart';
import 'auth_api_service.dart';
import '../../network/auth_interceptor.dart';
import '../../network/dio_client.dart';
/// Service principal d'authentification
@singleton
class AuthService {
final SecureTokenStorage _tokenStorage;
final AuthApiService _apiService;
final AuthInterceptor _authInterceptor;
final DioClient _dioClient;
// Stream controllers pour notifier les changements d'état
final _authStateController = StreamController<AuthState>.broadcast();
final _tokenRefreshController = StreamController<void>.broadcast();
// Timers pour la gestion automatique des tokens
Timer? _tokenRefreshTimer;
Timer? _sessionExpiryTimer;
// État actuel
AuthState _currentState = const AuthState.unknown();
AuthService(
this._tokenStorage,
this._apiService,
this._authInterceptor,
this._dioClient,
) {
_initializeAuthInterceptor();
}
// Getters
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
/// Initialise l'interceptor d'authentification
void _initializeAuthInterceptor() {
_authInterceptor.setCallbacks(
onTokenRefreshNeeded: () => _refreshTokenSilently(),
onAuthenticationFailed: () => logout(),
);
_dioClient.addAuthInterceptor(_authInterceptor);
}
/// Initialise le service d'authentification
Future<void> initialize() async {
_updateState(const AuthState.checking());
try {
// Vérifier si des tokens existent
final hasTokens = await _tokenStorage.hasAuthData();
if (!hasTokens) {
_updateState(const AuthState.unauthenticated());
return;
}
// Récupérer les données d'authentification
final authData = await _tokenStorage.getAuthData();
if (authData == null) {
_updateState(const AuthState.unauthenticated());
return;
}
// Vérifier si les tokens sont expirés
if (authData.isRefreshTokenExpired) {
await _tokenStorage.clearAuthData();
_updateState(const AuthState.unauthenticated());
return;
}
// Si le token d'accès est expiré, essayer de le rafraîchir
if (authData.isAccessTokenExpired) {
await _refreshToken();
return;
}
// Valider le token côté serveur
final isValid = await _validateTokenWithServer(authData.accessToken);
if (!isValid) {
await _refreshToken();
return;
}
// Tout est OK, restaurer la session
_updateState(AuthState.authenticated(
user: authData.user,
accessToken: authData.accessToken,
refreshToken: authData.refreshToken,
expiresAt: authData.expiresAt,
));
_scheduleTokenRefresh();
_scheduleSessionExpiry();
} catch (e) {
print('Erreur lors de l\'initialisation de l\'auth: $e');
await _tokenStorage.clearAuthData();
_updateState(AuthState.error('Erreur d\'initialisation: $e'));
}
}
/// Connecte un utilisateur
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Appel API de connexion
final response = await _apiService.login(request);
// Sauvegarder les tokens
await _tokenStorage.saveAuthData(response);
// Mettre à jour l'état
_updateState(AuthState.authenticated(
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expiresAt: response.expiresAt,
));
// Programmer les rafraîchissements
_scheduleTokenRefresh();
_scheduleSessionExpiry();
} on AuthApiException catch (e) {
_updateState(AuthState.error(e.message));
rethrow;
} catch (e) {
final errorMessage = 'Erreur de connexion: $e';
_updateState(AuthState.error(errorMessage));
rethrow;
}
}
/// Déconnecte l'utilisateur
Future<void> logout() async {
try {
// Récupérer le refresh token pour l'invalider côté serveur
final refreshToken = await _tokenStorage.getRefreshToken();
// Appel API de déconnexion (optionnel)
await _apiService.logout(refreshToken);
} catch (e) {
print('Erreur lors de la déconnexion serveur: $e');
}
// Nettoyage local (toujours fait)
await _tokenStorage.clearAuthData();
_cancelTimers();
_updateState(const AuthState.unauthenticated());
}
/// Rafraîchit le token d'accès
Future<void> _refreshToken() async {
try {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null) {
throw Exception('Aucun refresh token disponible');
}
// Vérifier si le refresh token est expiré
final refreshExpiresAt = await _tokenStorage.getRefreshTokenExpirationDate();
if (refreshExpiresAt != null && DateTime.now().isAfter(refreshExpiresAt)) {
throw Exception('Refresh token expiré');
}
// Appel API de refresh
final response = await _apiService.refreshToken(refreshToken);
// Mettre à jour le stockage
await _tokenStorage.updateAccessToken(response.accessToken, response.expiresAt);
// Mettre à jour l'état
if (_currentState.isAuthenticated) {
_updateState(_currentState.copyWith(
accessToken: response.accessToken,
expiresAt: response.expiresAt,
));
} else {
_updateState(AuthState.authenticated(
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expiresAt: response.expiresAt,
));
}
// Reprogrammer les timers
_scheduleTokenRefresh();
} catch (e) {
print('Erreur lors du rafraîchissement du token: $e');
await logout();
}
}
/// Rafraîchit le token silencieusement (sans changer l'état de loading)
Future<void> _refreshTokenSilently() async {
try {
await _refreshToken();
_tokenRefreshController.add(null);
} catch (e) {
print('Erreur lors du rafraîchissement silencieux: $e');
}
}
/// Valide un token côté serveur
Future<bool> _validateTokenWithServer(String accessToken) async {
try {
return await _apiService.validateToken(accessToken);
} catch (e) {
print('Erreur lors de la validation du token: $e');
return false;
}
}
/// Programme le rafraîchissement automatique du token
void _scheduleTokenRefresh() {
_tokenRefreshTimer?.cancel();
if (!_currentState.isAuthenticated || _currentState.expiresAt == null) {
return;
}
// Rafraîchir 5 minutes avant l'expiration
final refreshTime = _currentState.expiresAt!.subtract(const Duration(minutes: 5));
final delay = refreshTime.difference(DateTime.now());
if (delay.isNegative) {
// Le token expire bientôt, rafraîchir immédiatement
_refreshTokenSilently();
return;
}
_tokenRefreshTimer = Timer(delay, () => _refreshTokenSilently());
}
/// Programme l'expiration de la session
void _scheduleSessionExpiry() {
_sessionExpiryTimer?.cancel();
if (!_currentState.isAuthenticated || _currentState.expiresAt == null) {
return;
}
final delay = _currentState.expiresAt!.difference(DateTime.now());
if (delay.isNegative) {
logout();
return;
}
_sessionExpiryTimer = Timer(delay, () {
_updateState(const AuthState.expired());
});
}
/// Annule tous les timers
void _cancelTimers() {
_tokenRefreshTimer?.cancel();
_sessionExpiryTimer?.cancel();
_tokenRefreshTimer = null;
_sessionExpiryTimer = null;
}
/// Met à jour l'état et notifie les listeners
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
/// Nettoie les ressources
void dispose() {
_cancelTimers();
_authStateController.close();
_tokenRefreshController.close();
}
/// Vérifie les permissions de l'utilisateur
bool hasRole(String role) {
return _currentState.user?.role == role;
}
/// Vérifie si l'utilisateur a un des rôles spécifiés
bool hasAnyRole(List<String> roles) {
final userRole = _currentState.user?.role;
return userRole != null && roles.contains(userRole);
}
/// Décode un token JWT (utilitaire)
Map<String, dynamic>? decodeToken(String token) {
try {
return JwtDecoder.decode(token);
} catch (e) {
print('Erreur lors du décodage du token: $e');
return null;
}
}
/// Vérifie si un token est expiré
bool isTokenExpired(String token) {
try {
return JwtDecoder.isExpired(token);
} catch (e) {
return true;
}
}
}

View File

@@ -0,0 +1,418 @@
/// Service d'Authentification Keycloak
/// Gère l'authentification avec votre instance Keycloak sur port 8180
library keycloak_auth_service;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import 'keycloak_role_mapper.dart';
import 'keycloak_webview_auth_service.dart';
/// Configuration Keycloak pour votre instance
class KeycloakConfig {
/// URL de base de votre Keycloak
static const String baseUrl = 'http://192.168.1.145:8180';
/// Realm UnionFlow
static const String realm = 'unionflow';
/// Client ID pour l'application mobile
static const String clientId = 'unionflow-mobile';
/// URL de redirection après authentification
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
/// Scopes demandés
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
/// Endpoints calculés
static String get authorizationEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
static String get tokenEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get userInfoEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
static String get logoutEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
}
/// Service d'authentification Keycloak ultra-sophistiqué
class KeycloakAuthService {
static const FlutterAppAuth _appAuth = FlutterAppAuth();
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
// Clés de stockage sécurisé
static const String _accessTokenKey = 'keycloak_access_token';
static const String _refreshTokenKey = 'keycloak_refresh_token';
static const String _idTokenKey = 'keycloak_id_token';
static const String _userInfoKey = 'keycloak_user_info';
/// Authentification avec Keycloak via WebView (solution HTTP compatible)
///
/// Cette méthode utilise maintenant KeycloakWebViewAuthService pour contourner
/// les limitations HTTPS de flutter_appauth
static Future<AuthorizationTokenResponse?> authenticate() async {
try {
debugPrint('🔐 Démarrage authentification Keycloak via WebView...');
// Utiliser le service WebView pour l'authentification
// Cette méthode retourne null car l'authentification WebView
// est gérée différemment (via callback)
debugPrint('💡 Authentification WebView - utilisez authenticateWithWebView() à la place');
return null;
} catch (e, stackTrace) {
debugPrint('💥 Erreur authentification Keycloak: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Rafraîchit le token d'accès
static Future<TokenResponse?> refreshToken() async {
try {
final String? refreshToken = await _secureStorage.read(
key: _refreshTokenKey,
);
if (refreshToken == null) {
debugPrint('❌ Aucun refresh token disponible');
return null;
}
debugPrint('🔄 Rafraîchissement du token...');
final TokenRequest request = TokenRequest(
KeycloakConfig.clientId,
KeycloakConfig.redirectUrl,
refreshToken: refreshToken,
serviceConfiguration: AuthorizationServiceConfiguration(
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
tokenEndpoint: KeycloakConfig.tokenEndpoint,
),
);
final TokenResponse? result = await _appAuth.token(request);
if (result != null) {
await _storeTokens(result);
debugPrint('✅ Token rafraîchi avec succès');
return result;
}
debugPrint('❌ Échec du rafraîchissement du token');
return null;
} catch (e, stackTrace) {
debugPrint('💥 Erreur rafraîchissement token: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Récupère l'utilisateur authentifié depuis les tokens
static Future<User?> getCurrentUser() async {
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
final String? idToken = await _secureStorage.read(
key: _idTokenKey,
);
if (accessToken == null || idToken == null) {
debugPrint('❌ Tokens manquants');
return null;
}
// Vérifier si les tokens sont expirés
if (JwtDecoder.isExpired(accessToken)) {
debugPrint('⏰ Access token expiré, tentative de rafraîchissement...');
final TokenResponse? refreshResult = await refreshToken();
if (refreshResult == null) {
debugPrint('❌ Impossible de rafraîchir le token');
return null;
}
}
// Décoder les tokens JWT
final Map<String, dynamic> accessTokenPayload =
JwtDecoder.decode(accessToken);
final Map<String, dynamic> idTokenPayload =
JwtDecoder.decode(idToken);
debugPrint('🔍 Payload Access Token: $accessTokenPayload');
debugPrint('🔍 Payload ID Token: $idTokenPayload');
// Extraire les informations utilisateur
final String userId = idTokenPayload['sub'] ?? '';
final String email = idTokenPayload['email'] ?? '';
final String firstName = idTokenPayload['given_name'] ?? '';
final String lastName = idTokenPayload['family_name'] ?? '';
final String fullName = idTokenPayload['name'] ?? '$firstName $lastName';
// Extraire les rôles Keycloak
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
debugPrint('🎭 Rôles Keycloak extraits: $keycloakRoles');
// Si aucun rôle, assigner un rôle par défaut
if (keycloakRoles.isEmpty) {
debugPrint('⚠️ Aucun rôle trouvé, assignation du rôle MEMBER par défaut');
keycloakRoles.add('member');
}
// Mapper vers notre système de rôles
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
debugPrint('🎯 Rôle principal mappé: ${primaryRole.displayName}');
debugPrint('🔐 Permissions mappées: ${permissions.length} permissions');
debugPrint('📋 Permissions détaillées: $permissions');
// Créer l'utilisateur
final User user = User(
id: userId,
email: email,
firstName: firstName,
lastName: lastName,
primaryRole: primaryRole,
organizationContexts: [], // À implémenter selon vos besoins
additionalPermissions: permissions,
revokedPermissions: [],
preferences: const UserPreferences(),
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(), // À récupérer depuis Keycloak si disponible
isActive: true,
);
// Stocker les informations utilisateur
await _secureStorage.write(
key: _userInfoKey,
value: jsonEncode(user.toJson()),
);
debugPrint('✅ Utilisateur récupéré: ${user.fullName} (${user.primaryRole.displayName})');
return user;
} catch (e, stackTrace) {
debugPrint('💥 Erreur récupération utilisateur: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Déconnexion complète
static Future<bool> logout() async {
try {
debugPrint('🚪 Déconnexion Keycloak...');
final String? idToken = await _secureStorage.read(key: _idTokenKey);
// Déconnexion côté Keycloak si possible
if (idToken != null) {
try {
final EndSessionRequest request = EndSessionRequest(
idTokenHint: idToken,
postLogoutRedirectUrl: KeycloakConfig.redirectUrl,
serviceConfiguration: AuthorizationServiceConfiguration(
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
tokenEndpoint: KeycloakConfig.tokenEndpoint,
endSessionEndpoint: KeycloakConfig.logoutEndpoint,
),
);
await _appAuth.endSession(request);
debugPrint('✅ Déconnexion Keycloak côté serveur réussie');
} catch (e) {
debugPrint('⚠️ Déconnexion côté serveur échouée: $e');
// Continue quand même avec la déconnexion locale
}
}
// Nettoyage local des tokens
await _clearTokens();
debugPrint('✅ Déconnexion locale terminée');
return true;
} catch (e, stackTrace) {
debugPrint('💥 Erreur déconnexion: $e');
debugPrint('Stack trace: $stackTrace');
return false;
}
}
/// Vérifie si l'utilisateur est authentifié
static Future<bool> isAuthenticated() async {
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
if (accessToken == null) {
return false;
}
// Vérifier si le token est expiré
if (JwtDecoder.isExpired(accessToken)) {
// Tenter de rafraîchir
final TokenResponse? refreshResult = await refreshToken();
return refreshResult != null;
}
return true;
} catch (e) {
debugPrint('💥 Erreur vérification authentification: $e');
return false;
}
}
/// Stocke les tokens de manière sécurisée
static Future<void> _storeTokens(TokenResponse tokenResponse) async {
if (tokenResponse.accessToken != null) {
await _secureStorage.write(
key: _accessTokenKey,
value: tokenResponse.accessToken!,
);
}
if (tokenResponse.refreshToken != null) {
await _secureStorage.write(
key: _refreshTokenKey,
value: tokenResponse.refreshToken!,
);
}
if (tokenResponse.idToken != null) {
await _secureStorage.write(
key: _idTokenKey,
value: tokenResponse.idToken!,
);
}
debugPrint('🔒 Tokens stockés de manière sécurisée');
}
/// Nettoie tous les tokens stockés
static Future<void> _clearTokens() async {
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
await _secureStorage.delete(key: _idTokenKey);
await _secureStorage.delete(key: _userInfoKey);
debugPrint('🧹 Tokens nettoyés');
}
/// Extrait les rôles depuis le payload JWT Keycloak
static List<String> _extractKeycloakRoles(Map<String, dynamic> payload) {
final List<String> roles = [];
try {
// Rôles du realm
final Map<String, dynamic>? realmAccess = payload['realm_access'];
if (realmAccess != null && realmAccess['roles'] is List) {
final List<dynamic> realmRoles = realmAccess['roles'];
roles.addAll(realmRoles.cast<String>());
}
// Rôles des clients
final Map<String, dynamic>? resourceAccess = payload['resource_access'];
if (resourceAccess != null) {
resourceAccess.forEach((clientId, clientData) {
if (clientData is Map<String, dynamic> && clientData['roles'] is List) {
final List<dynamic> clientRoles = clientData['roles'];
roles.addAll(clientRoles.cast<String>());
}
});
}
// Filtrer les rôles système Keycloak
return roles.where((role) =>
!role.startsWith('default-roles-') &&
role != 'offline_access' &&
role != 'uma_authorization'
).toList();
} catch (e) {
debugPrint('💥 Erreur extraction rôles: $e');
return [];
}
}
/// Récupère le token d'accès actuel
static Future<String?> getAccessToken() async {
try {
final String? accessToken = await _secureStorage.read(
key: _accessTokenKey,
);
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
return accessToken;
}
// Token expiré, tenter de rafraîchir
final TokenResponse? refreshResult = await refreshToken();
return refreshResult?.accessToken;
} catch (e) {
debugPrint('💥 Erreur récupération access token: $e');
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// MÉTHODES WEBVIEW - Délégation vers KeycloakWebViewAuthService
// ═══════════════════════════════════════════════════════════════════════════
/// Prépare l'authentification WebView
///
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
static Future<Map<String, String>> prepareWebViewAuthentication() async {
return KeycloakWebViewAuthService.prepareAuthentication();
}
/// Traite le callback WebView et finalise l'authentification
///
/// Cette méthode doit être appelée quand l'URL de callback est interceptée
static Future<User> handleWebViewCallback(String callbackUrl) async {
return KeycloakWebViewAuthService.handleAuthCallback(callbackUrl);
}
/// Vérifie si l'utilisateur est authentifié (compatible WebView)
static Future<bool> isWebViewAuthenticated() async {
return KeycloakWebViewAuthService.isAuthenticated();
}
/// Récupère l'utilisateur authentifié (compatible WebView)
static Future<User?> getCurrentWebViewUser() async {
return KeycloakWebViewAuthService.getCurrentUser();
}
/// Déconnecte l'utilisateur (compatible WebView)
static Future<bool> logoutWebView() async {
return KeycloakWebViewAuthService.logout();
}
/// Nettoie les données d'authentification WebView
static Future<void> clearWebViewAuthData() async {
return KeycloakWebViewAuthService.clearAuthData();
}
}

View File

@@ -0,0 +1,246 @@
/// Mapper de Rôles Keycloak vers UserRole
/// Convertit les rôles Keycloak existants vers notre système de rôles sophistiqué
library keycloak_role_mapper;
import '../models/user_role.dart';
import '../models/permission_matrix.dart';
/// Service de mapping des rôles Keycloak
class KeycloakRoleMapper {
/// Mapping des rôles Keycloak vers UserRole
static const Map<String, UserRole> _keycloakToUserRole = {
// Rôles administratifs
'ADMIN': UserRole.superAdmin,
'PRESIDENT': UserRole.orgAdmin,
// Rôles de gestion
'TRESORIER': UserRole.moderator,
'SECRETAIRE': UserRole.moderator,
'GESTIONNAIRE_MEMBRE': UserRole.moderator,
'ORGANISATEUR_EVENEMENT': UserRole.moderator,
// Rôles membres
'MEMBRE': UserRole.activeMember,
};
/// Mapping des rôles Keycloak vers permissions spécifiques
static const Map<String, List<String>> _keycloakToPermissions = {
'ADMIN': [
// Permissions Super Admin - Accès total
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
],
'PRESIDENT': [
// Permissions Président - Gestion organisation
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.COMM_SEND_ALL,
],
'TRESORIER': [
// Permissions Trésorier - Focus finances
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_VIEW,
],
'SECRETAIRE': [
// Permissions Secrétaire - Communication et membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.COMM_SEND_ALL,
PermissionMatrix.COMM_MODERATE,
PermissionMatrix.DASHBOARD_VIEW,
],
'GESTIONNAIRE_MEMBRE': [
// Permissions Gestionnaire de Membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_CREATE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'ORGANISATEUR_EVENEMENT': [
// Permissions Organisateur d'Événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'MEMBRE': [
// Permissions Membre Standard
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
PermissionMatrix.EVENTS_VIEW_PUBLIC,
PermissionMatrix.EVENTS_PARTICIPATE,
PermissionMatrix.SOLIDARITY_VIEW_PUBLIC,
PermissionMatrix.SOLIDARITY_PARTICIPATE,
PermissionMatrix.FINANCES_VIEW_OWN,
PermissionMatrix.DASHBOARD_VIEW,
],
};
/// Mappe une liste de rôles Keycloak vers le UserRole principal
static UserRole mapToUserRole(List<String> keycloakRoles) {
// Priorité des rôles (du plus élevé au plus bas)
const List<String> rolePriority = [
'ADMIN',
'PRESIDENT',
'TRESORIER',
'SECRETAIRE',
'GESTIONNAIRE_MEMBRE',
'ORGANISATEUR_EVENEMENT',
'MEMBRE',
];
// Trouver le rôle avec la priorité la plus élevée
for (final String priorityRole in rolePriority) {
if (keycloakRoles.contains(priorityRole)) {
return _keycloakToUserRole[priorityRole] ?? UserRole.simpleMember;
}
}
// Par défaut, visiteur si aucun rôle reconnu
return UserRole.visitor;
}
/// Mappe une liste de rôles Keycloak vers les permissions
static List<String> mapToPermissions(List<String> keycloakRoles) {
final Set<String> permissions = <String>{};
// Ajouter les permissions pour chaque rôle
for (final String role in keycloakRoles) {
final List<String>? rolePermissions = _keycloakToPermissions[role];
if (rolePermissions != null) {
permissions.addAll(rolePermissions);
}
}
// Ajouter les permissions de base pour tous les utilisateurs authentifiés
permissions.add(PermissionMatrix.DASHBOARD_VIEW);
permissions.add(PermissionMatrix.MEMBERS_VIEW_OWN);
return permissions.toList();
}
/// Vérifie si un rôle Keycloak est reconnu
static bool isValidKeycloakRole(String role) {
return _keycloakToUserRole.containsKey(role);
}
/// Récupère tous les rôles Keycloak supportés
static List<String> getSupportedKeycloakRoles() {
return _keycloakToUserRole.keys.toList();
}
/// Récupère le UserRole correspondant à un rôle Keycloak spécifique
static UserRole? getUserRoleForKeycloakRole(String keycloakRole) {
return _keycloakToUserRole[keycloakRole];
}
/// Récupère les permissions pour un rôle Keycloak spécifique
static List<String> getPermissionsForKeycloakRole(String keycloakRole) {
return _keycloakToPermissions[keycloakRole] ?? [];
}
/// Analyse détaillée du mapping des rôles
static Map<String, dynamic> analyzeRoleMapping(List<String> keycloakRoles) {
final UserRole primaryRole = mapToUserRole(keycloakRoles);
final List<String> permissions = mapToPermissions(keycloakRoles);
final Map<String, List<String>> roleBreakdown = {};
for (final String role in keycloakRoles) {
if (isValidKeycloakRole(role)) {
roleBreakdown[role] = getPermissionsForKeycloakRole(role);
}
}
return {
'keycloakRoles': keycloakRoles,
'primaryRole': primaryRole.name,
'primaryRoleDisplayName': primaryRole.displayName,
'totalPermissions': permissions.length,
'permissions': permissions,
'roleBreakdown': roleBreakdown,
'unrecognizedRoles': keycloakRoles
.where((role) => !isValidKeycloakRole(role))
.toList(),
};
}
/// Suggestions d'amélioration du mapping
static Map<String, dynamic> getMappingSuggestions(List<String> keycloakRoles) {
final List<String> unrecognized = keycloakRoles
.where((role) => !isValidKeycloakRole(role))
.toList();
final List<String> suggestions = [];
if (unrecognized.isNotEmpty) {
suggestions.add(
'Rôles non reconnus détectés: ${unrecognized.join(", ")}. '
'Considérez ajouter ces rôles au mapping ou les ignorer.',
);
}
if (keycloakRoles.isEmpty) {
suggestions.add(
'Aucun rôle Keycloak détecté. L\'utilisateur sera traité comme visiteur.',
);
}
final UserRole primaryRole = mapToUserRole(keycloakRoles);
if (primaryRole == UserRole.visitor && keycloakRoles.isNotEmpty) {
suggestions.add(
'L\'utilisateur a des rôles Keycloak mais est mappé comme visiteur. '
'Vérifiez la configuration du mapping.',
);
}
return {
'unrecognizedRoles': unrecognized,
'suggestions': suggestions,
'mappingHealth': suggestions.isEmpty ? 'excellent' : 'needs_attention',
};
}
}

View File

@@ -1,373 +1,671 @@
/// Service d'Authentification Keycloak via WebView
///
/// Implémentation professionnelle et sécurisée de l'authentification OAuth2/OIDC
/// avec Keycloak utilisant WebView pour contourner les limitations HTTPS de flutter_appauth.
///
/// Fonctionnalités :
/// - Flow OAuth2 Authorization Code avec PKCE
/// - Gestion sécurisée des tokens JWT
/// - Support HTTP/HTTPS
/// - Gestion complète des erreurs et timeouts
/// - Validation rigoureuse des paramètres
/// - Logging détaillé pour le debugging
library keycloak_webview_auth_service;
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
import 'package:http/http.dart' as http;
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../models/auth_state.dart';
import '../models/user_info.dart';
import 'package:dio/dio.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import 'keycloak_role_mapper.dart';
@singleton
/// Configuration Keycloak pour l'authentification WebView
class KeycloakWebViewConfig {
/// URL de base de l'instance Keycloak
static const String baseUrl = 'http://192.168.1.145:8180';
/// Realm UnionFlow
static const String realm = 'unionflow';
/// Client ID pour l'application mobile
static const String clientId = 'unionflow-mobile';
/// URL de redirection après authentification
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
/// Scopes OAuth2 demandés
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
/// Timeout pour les requêtes HTTP (en secondes)
static const int httpTimeoutSeconds = 30;
/// Timeout pour l'authentification WebView (en secondes)
static const int authTimeoutSeconds = 300; // 5 minutes
/// Endpoints calculés
static String get authorizationEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
static String get tokenEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get userInfoEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
static String get logoutEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
static String get jwksEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/certs';
}
/// Résultat de l'authentification WebView
class WebViewAuthResult {
final String accessToken;
final String idToken;
final String? refreshToken;
final int expiresIn;
final String tokenType;
final List<String> scopes;
const WebViewAuthResult({
required this.accessToken,
required this.idToken,
this.refreshToken,
required this.expiresIn,
required this.tokenType,
required this.scopes,
});
/// Création depuis la réponse token de Keycloak
factory WebViewAuthResult.fromTokenResponse(Map<String, dynamic> response) {
return WebViewAuthResult(
accessToken: response['access_token'] ?? '',
idToken: response['id_token'] ?? '',
refreshToken: response['refresh_token'],
expiresIn: response['expires_in'] ?? 3600,
tokenType: response['token_type'] ?? 'Bearer',
scopes: (response['scope'] as String?)?.split(' ') ?? [],
);
}
}
/// Exceptions spécifiques à l'authentification WebView
class KeycloakWebViewAuthException implements Exception {
final String message;
final String? code;
final dynamic originalError;
const KeycloakWebViewAuthException(
this.message, {
this.code,
this.originalError,
});
@override
String toString() => 'KeycloakWebViewAuthException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Service d'authentification Keycloak via WebView
///
/// Implémentation complète et sécurisée du flow OAuth2 Authorization Code avec PKCE
class KeycloakWebViewAuthService {
static const String _keycloakBaseUrl = 'http://192.168.1.11:8180';
static const String _realm = 'unionflow';
static const String _clientId = 'unionflow-mobile';
static const String _redirectUrl = 'http://192.168.1.11:8080/auth/callback';
// Stockage sécurisé des tokens
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
final Dio _dio = Dio();
// Clés de stockage sécurisé
static const String _accessTokenKey = 'keycloak_webview_access_token';
static const String _idTokenKey = 'keycloak_webview_id_token';
static const String _refreshTokenKey = 'keycloak_webview_refresh_token';
static const String _userInfoKey = 'keycloak_webview_user_info';
static const String _authStateKey = 'keycloak_webview_auth_state';
// Stream pour l'état d'authentification
final _authStateController = StreamController<AuthState>.broadcast();
Stream<AuthState> get authStateStream => _authStateController.stream;
// Client HTTP avec timeout configuré
static final http.Client _httpClient = http.Client();
AuthState _currentState = const AuthState.unauthenticated();
AuthState get currentState => _currentState;
KeycloakWebViewAuthService() {
_initializeAuthState();
/// Génère un code verifier PKCE sécurisé
static String _generateCodeVerifier() {
const String charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final Random random = Random.secure();
return List.generate(128, (i) => charset[random.nextInt(charset.length)]).join();
}
Future<void> _initializeAuthState() async {
print('🔄 Initialisation du service d\'authentification WebView...');
/// Génère le code challenge PKCE à partir du verifier
static String _generateCodeChallenge(String verifier) {
final List<int> bytes = utf8.encode(verifier);
final Digest digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Génère un state sécurisé pour la protection CSRF
static String _generateState() {
final Random random = Random.secure();
final List<int> bytes = List.generate(32, (i) => random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
/// Construit l'URL d'autorisation Keycloak avec tous les paramètres
static Future<Map<String, String>> _buildAuthorizationUrl() async {
final String codeVerifier = _generateCodeVerifier();
final String codeChallenge = _generateCodeChallenge(codeVerifier);
final String state = _generateState();
try {
final accessToken = await _secureStorage.read(key: 'access_token');
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
final userInfo = await _getUserInfoFromToken(accessToken);
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (userInfo != null && refreshToken != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(accessToken)['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
));
return;
}
}
// Tentative de refresh si le token d'accès est expiré
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (refreshToken != null && !JwtDecoder.isExpired(refreshToken)) {
final success = await _refreshTokens();
if (success) return;
}
// Aucun token valide trouvé
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
} catch (e) {
print('❌ Erreur lors de l\'initialisation: $e');
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
}
}
Future<void> loginWithWebView(BuildContext context) async {
print('🔐 Début de la connexion Keycloak WebView...');
// Stocker les paramètres pour la validation ultérieure
await _secureStorage.write(
key: _authStateKey,
value: jsonEncode({
'code_verifier': codeVerifier,
'state': state,
'timestamp': DateTime.now().millisecondsSinceEpoch,
}),
);
try {
_updateAuthState(const AuthState.checking());
// Génération des paramètres PKCE
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
final state = _generateRandomString(32);
// Construction de l'URL d'autorisation
final authUrl = _buildAuthorizationUrl(codeChallenge, state);
print('🌐 URL d\'autorisation: $authUrl');
// Ouverture de la WebView
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (context) => KeycloakWebViewPage(
authUrl: authUrl,
redirectUrl: _redirectUrl,
),
),
);
if (result != null) {
// Traitement du code d'autorisation
await _handleAuthorizationCode(result, codeVerifier, state);
} else {
print('❌ Authentification annulée par l\'utilisateur');
_updateAuthState(const AuthState.unauthenticated());
}
} catch (e) {
print('❌ Erreur lors de la connexion: $e');
_updateAuthState(const AuthState.unauthenticated());
rethrow;
}
}
String _buildAuthorizationUrl(String codeChallenge, String state) {
final params = {
'client_id': _clientId,
'redirect_uri': _redirectUrl,
final Map<String, String> params = {
'response_type': 'code',
'scope': 'openid profile email',
'client_id': KeycloakWebViewConfig.clientId,
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
'scope': KeycloakWebViewConfig.scopes.join(' '),
'state': state,
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': state,
'kc_locale': 'fr',
'prompt': 'login',
};
final queryString = params.entries
final String queryString = params.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return '$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/auth?$queryString';
final String authUrl = '${KeycloakWebViewConfig.authorizationEndpoint}?$queryString';
debugPrint('🔐 URL d\'autorisation générée: $authUrl');
return {
'url': authUrl,
'state': state,
'code_verifier': codeVerifier,
};
}
/// Valide la réponse de redirection et extrait le code d'autorisation
static Future<String> _validateCallbackAndExtractCode(
String callbackUrl,
String expectedState,
) async {
debugPrint('🔍 Validation du callback: $callbackUrl');
final Uri uri = Uri.parse(callbackUrl);
// Vérifier que c'est bien notre URL de redirection
if (!callbackUrl.startsWith(KeycloakWebViewConfig.redirectUrl)) {
throw const KeycloakWebViewAuthException(
'URL de callback invalide',
code: 'INVALID_CALLBACK_URL',
);
}
// Vérifier la présence d'erreurs
final String? error = uri.queryParameters['error'];
if (error != null) {
final String? errorDescription = uri.queryParameters['error_description'];
throw KeycloakWebViewAuthException(
'Erreur d\'authentification: ${errorDescription ?? error}',
code: error,
);
}
// Valider le state pour la protection CSRF
final String? receivedState = uri.queryParameters['state'];
if (receivedState != expectedState) {
throw const KeycloakWebViewAuthException(
'State invalide - possible attaque CSRF',
code: 'INVALID_STATE',
);
}
// Extraire le code d'autorisation
final String? code = uri.queryParameters['code'];
if (code == null || code.isEmpty) {
throw const KeycloakWebViewAuthException(
'Code d\'autorisation manquant',
code: 'MISSING_AUTH_CODE',
);
}
debugPrint('✅ Code d\'autorisation extrait avec succès');
return code;
}
Future<void> _handleAuthorizationCode(String authCode, String codeVerifier, String expectedState) async {
print('🔄 Traitement du code d\'autorisation...');
/// Échange le code d'autorisation contre des tokens
static Future<WebViewAuthResult> _exchangeCodeForTokens(
String authCode,
String codeVerifier,
) async {
debugPrint('🔄 Échange du code d\'autorisation contre les tokens...');
try {
// Échange du code contre des tokens
final response = await _dio.post(
'$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/token',
data: {
'grant_type': 'authorization_code',
'client_id': _clientId,
'code': authCode,
'redirect_uri': _redirectUrl,
'code_verifier': codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
if (response.statusCode == 200) {
final tokens = response.data;
await _storeTokens(tokens);
final userInfo = await _getUserInfoFromToken(tokens['access_token']);
if (userInfo != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(tokens['access_token'])['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: tokens['access_token'],
refreshToken: tokens['refresh_token'],
expiresAt: expiresAt,
));
print('✅ Authentification réussie pour: ${userInfo.email}');
final Map<String, String> body = {
'grant_type': 'authorization_code',
'client_id': KeycloakWebViewConfig.clientId,
'code': authCode,
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
'code_verifier': codeVerifier,
};
final http.Response response = await _httpClient
.post(
Uri.parse(KeycloakWebViewConfig.tokenEndpoint),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body,
)
.timeout(Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds));
debugPrint('📡 Réponse token endpoint: ${response.statusCode}');
if (response.statusCode != 200) {
final String errorBody = response.body;
debugPrint('❌ Erreur échange tokens: $errorBody');
Map<String, dynamic>? errorJson;
try {
errorJson = jsonDecode(errorBody);
} catch (e) {
// Ignore JSON parsing errors
}
final String errorMessage = errorJson?['error_description'] ??
errorJson?['error'] ??
'Erreur HTTP ${response.statusCode}';
throw KeycloakWebViewAuthException(
'Échec de l\'échange de tokens: $errorMessage',
code: errorJson?['error'],
);
}
final Map<String, dynamic> tokenResponse = jsonDecode(response.body);
// Valider la présence des tokens requis
if (!tokenResponse.containsKey('access_token') ||
!tokenResponse.containsKey('id_token')) {
throw const KeycloakWebViewAuthException(
'Tokens manquants dans la réponse',
code: 'MISSING_TOKENS',
);
}
debugPrint('✅ Tokens reçus avec succès');
return WebViewAuthResult.fromTokenResponse(tokenResponse);
} on TimeoutException {
throw const KeycloakWebViewAuthException(
'Timeout lors de l\'échange des tokens',
code: 'TIMEOUT',
);
} catch (e) {
print('❌ Erreur lors de l\'échange de tokens: $e');
_updateAuthState(const AuthState.unauthenticated());
rethrow;
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de l\'échange des tokens: $e',
originalError: e,
);
}
}
// Méthodes utilitaires PKCE
String _generateCodeVerifier() {
final random = Random.secure();
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
/// Stocke les tokens de manière sécurisée
static Future<void> _storeTokens(WebViewAuthResult authResult) async {
debugPrint('💾 Stockage sécurisé des tokens...');
String _generateCodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
String _generateRandomString(int length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (index) => chars[random.nextInt(chars.length)]).join();
}
Future<UserInfo?> _getUserInfoFromToken(String accessToken) async {
try {
final decodedToken = JwtDecoder.decode(accessToken);
final roles = List<String>.from(decodedToken['realm_access']?['roles'] ?? []);
final primaryRole = roles.isNotEmpty ? roles.first : 'membre';
await Future.wait([
_secureStorage.write(key: _accessTokenKey, value: authResult.accessToken),
_secureStorage.write(key: _idTokenKey, value: authResult.idToken),
if (authResult.refreshToken != null)
_secureStorage.write(key: _refreshTokenKey, value: authResult.refreshToken!),
]);
return UserInfo(
id: decodedToken['sub'] ?? '',
email: decodedToken['email'] ?? '',
firstName: decodedToken['given_name'] ?? '',
lastName: decodedToken['family_name'] ?? '',
role: primaryRole,
roles: roles,
debugPrint('✅ Tokens stockés avec succès');
} catch (e) {
throw KeycloakWebViewAuthException(
'Erreur lors du stockage des tokens: $e',
originalError: e,
);
}
}
/// Valide et parse un token JWT
static Map<String, dynamic> _parseAndValidateJWT(String token, String tokenType) {
try {
// Vérifier l'expiration
if (JwtDecoder.isExpired(token)) {
throw KeycloakWebViewAuthException(
'$tokenType expiré',
code: 'TOKEN_EXPIRED',
);
}
// Parser le payload
final Map<String, dynamic> payload = JwtDecoder.decode(token);
// Validations de base
if (payload['iss'] == null) {
throw const KeycloakWebViewAuthException(
'Token JWT invalide: issuer manquant',
code: 'INVALID_JWT',
);
}
// Vérifier l'issuer
final String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}';
if (payload['iss'] != expectedIssuer) {
throw KeycloakWebViewAuthException(
'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})',
code: 'INVALID_ISSUER',
);
}
debugPrint('$tokenType validé avec succès');
return payload;
} catch (e) {
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de la validation du $tokenType: $e',
originalError: e,
);
}
}
/// Méthode principale d'authentification
///
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
static Future<Map<String, String>> prepareAuthentication() async {
debugPrint('🚀 Préparation de l\'authentification WebView...');
try {
// Nettoyer les données d'authentification précédentes
await clearAuthData();
// Générer l'URL d'autorisation avec PKCE
final Map<String, String> authParams = await _buildAuthorizationUrl();
debugPrint('✅ Authentification préparée avec succès');
return authParams;
} catch (e) {
throw KeycloakWebViewAuthException(
'Erreur lors de la préparation de l\'authentification: $e',
originalError: e,
);
}
}
/// Traite le callback de redirection et finalise l'authentification
static Future<User> handleAuthCallback(String callbackUrl) async {
debugPrint('🔄 Traitement du callback d\'authentification...');
debugPrint('📋 URL de callback: $callbackUrl');
try {
// Récupérer les paramètres d'authentification stockés
debugPrint('🔍 Récupération de l\'état d\'authentification...');
final String? authStateJson = await _secureStorage.read(key: _authStateKey);
if (authStateJson == null) {
debugPrint('❌ État d\'authentification manquant');
throw const KeycloakWebViewAuthException(
'État d\'authentification manquant',
code: 'MISSING_AUTH_STATE',
);
}
final Map<String, dynamic> authState = jsonDecode(authStateJson);
final String expectedState = authState['state'];
final String codeVerifier = authState['code_verifier'];
debugPrint('✅ État d\'authentification récupéré');
// Valider le callback et extraire le code
debugPrint('🔍 Validation du callback...');
final String authCode = await _validateCallbackAndExtractCode(
callbackUrl,
expectedState,
);
debugPrint('✅ Code d\'autorisation extrait: ${authCode.substring(0, 10)}...');
// Échanger le code contre des tokens
debugPrint('🔄 Échange du code contre les tokens...');
final WebViewAuthResult authResult = await _exchangeCodeForTokens(
authCode,
codeVerifier,
);
debugPrint('✅ Tokens reçus avec succès');
// Stocker les tokens
debugPrint('💾 Stockage des tokens...');
await _storeTokens(authResult);
debugPrint('✅ Tokens stockés');
// Créer l'utilisateur depuis les tokens
debugPrint('👤 Création de l\'utilisateur...');
final User user = await _createUserFromTokens(authResult);
debugPrint('✅ Utilisateur créé: ${user.fullName}');
// Nettoyer l'état d'authentification temporaire
await _secureStorage.delete(key: _authStateKey);
debugPrint('🎉 Authentification WebView terminée avec succès');
return user;
} catch (e, stackTrace) {
debugPrint('💥 Erreur lors du traitement du callback: $e');
debugPrint('📋 Stack trace: $stackTrace');
// Nettoyer en cas d'erreur
await _secureStorage.delete(key: _authStateKey);
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors du traitement du callback: $e',
originalError: e,
);
}
}
/// Crée un utilisateur depuis les tokens JWT
static Future<User> _createUserFromTokens(WebViewAuthResult authResult) async {
debugPrint('👤 Création de l\'utilisateur depuis les tokens...');
try {
// Parser et valider les tokens
final Map<String, dynamic> accessTokenPayload = _parseAndValidateJWT(
authResult.accessToken,
'Access Token',
);
final Map<String, dynamic> idTokenPayload = _parseAndValidateJWT(
authResult.idToken,
'ID Token',
);
// Extraire les informations utilisateur
final String userId = idTokenPayload['sub'] ?? '';
final String email = idTokenPayload['email'] ?? '';
final String firstName = idTokenPayload['given_name'] ?? '';
final String lastName = idTokenPayload['family_name'] ?? '';
if (userId.isEmpty || email.isEmpty) {
throw const KeycloakWebViewAuthException(
'Informations utilisateur manquantes dans les tokens',
code: 'MISSING_USER_INFO',
);
}
// Extraire les rôles Keycloak
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
// Mapper vers notre système de rôles
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
// Créer l'utilisateur
final User user = User(
id: userId,
email: email,
firstName: firstName,
lastName: lastName,
primaryRole: primaryRole,
organizationContexts: const [],
additionalPermissions: permissions,
revokedPermissions: const [],
preferences: const UserPreferences(
language: 'fr',
theme: 'system',
notificationsEnabled: true,
emailNotifications: true,
pushNotifications: true,
dashboardLayout: 'adaptive',
timezone: 'Europe/Paris',
),
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
isActive: true,
);
// Stocker les informations utilisateur
await _secureStorage.write(
key: _userInfoKey,
value: jsonEncode(user.toJson()),
);
debugPrint('✅ Utilisateur créé: ${user.fullName} (${user.primaryRole.displayName})');
return user;
} catch (e) {
print('❌ Erreur lors de l\'extraction des infos utilisateur: $e');
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de la création de l\'utilisateur: $e',
originalError: e,
);
}
}
/// Extrait les rôles Keycloak depuis le payload du token
static List<String> _extractKeycloakRoles(Map<String, dynamic> tokenPayload) {
try {
final List<String> roles = <String>[];
// Rôles realm
final Map<String, dynamic>? realmAccess = tokenPayload['realm_access'];
if (realmAccess != null && realmAccess['roles'] is List) {
roles.addAll(List<String>.from(realmAccess['roles']));
}
// Rôles client
final Map<String, dynamic>? resourceAccess = tokenPayload['resource_access'];
if (resourceAccess != null) {
final Map<String, dynamic>? clientAccess = resourceAccess[KeycloakWebViewConfig.clientId];
if (clientAccess != null && clientAccess['roles'] is List) {
roles.addAll(List<String>.from(clientAccess['roles']));
}
}
// Filtrer les rôles système
return roles.where((role) =>
!role.startsWith('default-roles-') &&
role != 'offline_access' &&
role != 'uma_authorization'
).toList();
} catch (e) {
debugPrint('💥 Erreur extraction rôles: $e');
return [];
}
}
/// Nettoie toutes les données d'authentification
static Future<void> clearAuthData() async {
debugPrint('🧹 Nettoyage des données d\'authentification...');
try {
await Future.wait([
_secureStorage.delete(key: _accessTokenKey),
_secureStorage.delete(key: _idTokenKey),
_secureStorage.delete(key: _refreshTokenKey),
_secureStorage.delete(key: _userInfoKey),
_secureStorage.delete(key: _authStateKey),
]);
debugPrint('✅ Données d\'authentification nettoyées');
} catch (e) {
debugPrint('⚠️ Erreur lors du nettoyage: $e');
}
}
/// Vérifie si l'utilisateur est authentifié
static Future<bool> isAuthenticated() async {
try {
final String? accessToken = await _secureStorage.read(key: _accessTokenKey);
if (accessToken == null) {
return false;
}
// Vérifier si le token est expiré
return !JwtDecoder.isExpired(accessToken);
} catch (e) {
debugPrint('💥 Erreur vérification authentification: $e');
return false;
}
}
/// Récupère l'utilisateur authentifié
static Future<User?> getCurrentUser() async {
try {
final String? userInfoJson = await _secureStorage.read(key: _userInfoKey);
if (userInfoJson == null) {
return null;
}
final Map<String, dynamic> userJson = jsonDecode(userInfoJson);
return User.fromJson(userJson);
} catch (e) {
debugPrint('💥 Erreur récupération utilisateur: $e');
return null;
}
}
Future<void> _storeTokens(Map<String, dynamic> tokens) async {
await _secureStorage.write(key: 'access_token', value: tokens['access_token']);
await _secureStorage.write(key: 'refresh_token', value: tokens['refresh_token']);
if (tokens['id_token'] != null) {
await _secureStorage.write(key: 'id_token', value: tokens['id_token']);
}
}
/// Déconnecte l'utilisateur
static Future<bool> logout() async {
debugPrint('🚪 Déconnexion de l\'utilisateur...');
Future<bool> _refreshTokens() async {
try {
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (refreshToken == null) return false;
// Nettoyer les données locales
await clearAuthData();
final response = await _dio.post(
'$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/token',
data: {
'grant_type': 'refresh_token',
'client_id': _clientId,
'refresh_token': refreshToken,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
debugPrint('✅ Déconnexion réussie');
return true;
if (response.statusCode == 200) {
await _storeTokens(response.data);
final userInfo = await _getUserInfoFromToken(response.data['access_token']);
if (userInfo != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(response.data['access_token'])['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
expiresAt: expiresAt,
));
return true;
}
}
} catch (e) {
print(' Erreur lors du refresh: $e');
}
return false;
}
Future<void> logout() async {
print('🚪 Déconnexion...');
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
}
Future<void> _clearTokens() async {
await _secureStorage.delete(key: 'access_token');
await _secureStorage.delete(key: 'refresh_token');
await _secureStorage.delete(key: 'id_token');
}
void _updateAuthState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}
// Page WebView pour l'authentification
class KeycloakWebViewPage extends StatefulWidget {
final String authUrl;
final String redirectUrl;
const KeycloakWebViewPage({
Key? key,
required this.authUrl,
required this.redirectUrl,
}) : super(key: key);
@override
State<KeycloakWebViewPage> createState() => _KeycloakWebViewPageState();
}
class _KeycloakWebViewPageState extends State<KeycloakWebViewPage> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_initializeWebView();
}
void _initializeWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent('Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36')
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (NavigationRequest request) {
print('🌐 Navigation vers: ${request.url}');
if (request.url.startsWith(widget.redirectUrl)) {
// Extraction du code d'autorisation
final uri = Uri.parse(request.url);
final code = uri.queryParameters['code'];
if (code != null) {
print('✅ Code d\'autorisation reçu: $code');
Navigator.of(context).pop(code);
} else {
print('❌ Aucun code d\'autorisation trouvé');
Navigator.of(context).pop();
}
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onWebResourceError: (WebResourceError error) {
print('❌ Erreur WebView: ${error.description}');
print('❌ Code d\'erreur: ${error.errorCode}');
print('❌ URL qui a échoué: ${error.url}');
},
),
);
// Chargement avec gestion d'erreur
_loadUrlWithRetry();
}
Future<void> _loadUrlWithRetry() async {
try {
await _controller.loadRequest(Uri.parse(widget.authUrl));
} catch (e) {
print('❌ Erreur lors du chargement: $e');
// Retry avec une approche différente si nécessaire
debugPrint('💥 Erreur déconnexion: $e');
return false;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Connexion Keycloak'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
body: WebViewWidget(controller: _controller),
);
}
}

View File

@@ -0,0 +1,375 @@
/// Moteur de permissions ultra-performant avec cache intelligent
/// Vérifications contextuelles et audit trail intégré
library permission_engine;
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import '../models/permission_matrix.dart';
/// Moteur de permissions haute performance avec cache multi-niveaux
///
/// Fonctionnalités :
/// - Cache mémoire ultra-rapide avec TTL
/// - Vérifications contextuelles avancées
/// - Audit trail automatique
/// - Support des permissions héritées
/// - Invalidation intelligente du cache
class PermissionEngine {
static final PermissionEngine _instance = PermissionEngine._internal();
factory PermissionEngine() => _instance;
PermissionEngine._internal();
/// Cache mémoire des permissions avec TTL
static final Map<String, _CachedPermission> _permissionCache = {};
/// Cache des permissions effectives par utilisateur
static final Map<String, _CachedUserPermissions> _userPermissionsCache = {};
/// Durée de vie du cache (5 minutes par défaut)
static const Duration _defaultCacheTTL = Duration(minutes: 5);
/// Durée de vie du cache pour les super admins (plus long)
static const Duration _superAdminCacheTTL = Duration(minutes: 15);
/// Compteur de hits/miss du cache pour monitoring
static int _cacheHits = 0;
static int _cacheMisses = 0;
/// Stream pour les événements d'audit
static final StreamController<PermissionAuditEvent> _auditController =
StreamController<PermissionAuditEvent>.broadcast();
/// Stream des événements d'audit
static Stream<PermissionAuditEvent> get auditStream => _auditController.stream;
/// Vérifie si un utilisateur a une permission spécifique
///
/// [user] - Utilisateur à vérifier
/// [permission] - Permission à vérifier
/// [organizationId] - Contexte organisationnel optionnel
/// [auditLog] - Activer l'audit trail (défaut: true)
static Future<bool> hasPermission(
User user,
String permission, {
String? organizationId,
bool auditLog = true,
}) async {
final cacheKey = _generateCacheKey(user.id, permission, organizationId);
// Vérification du cache
final cachedResult = _getCachedPermission(cacheKey);
if (cachedResult != null) {
_cacheHits++;
if (auditLog && !cachedResult.result) {
_logAuditEvent(user, permission, false, 'CACHED_DENIED', organizationId);
}
return cachedResult.result;
}
_cacheMisses++;
// Calcul de la permission
final result = await _computePermission(user, permission, organizationId);
// Mise en cache
_cachePermission(cacheKey, result, user.primaryRole);
// Audit trail
if (auditLog) {
_logAuditEvent(
user,
permission,
result,
result ? 'GRANTED' : 'DENIED',
organizationId,
);
}
return result;
}
/// Vérifie plusieurs permissions en une seule fois
static Future<Map<String, bool>> hasPermissions(
User user,
List<String> permissions, {
String? organizationId,
bool auditLog = true,
}) async {
final results = <String, bool>{};
// Traitement en parallèle pour les performances
final futures = permissions.map((permission) =>
hasPermission(user, permission, organizationId: organizationId, auditLog: auditLog)
.then((result) => MapEntry(permission, result))
);
final entries = await Future.wait(futures);
for (final entry in entries) {
results[entry.key] = entry.value;
}
return results;
}
/// Obtient toutes les permissions effectives d'un utilisateur
static Future<List<String>> getEffectivePermissions(
User user, {
String? organizationId,
}) async {
final cacheKey = '${user.id}_effective_${organizationId ?? 'global'}';
// Vérification du cache utilisateur
final cachedUserPermissions = _getCachedUserPermissions(cacheKey);
if (cachedUserPermissions != null) {
_cacheHits++;
return cachedUserPermissions.permissions;
}
_cacheMisses++;
// Calcul des permissions effectives
final permissions = user.getEffectivePermissions(organizationId: organizationId);
// Mise en cache
_cacheUserPermissions(cacheKey, permissions, user.primaryRole);
return permissions;
}
/// Vérifie si un utilisateur peut effectuer une action sur un domaine
static Future<bool> canPerformAction(
User user,
String domain,
String action, {
String scope = 'own',
String? organizationId,
}) async {
final permission = '$domain.$action.$scope';
return hasPermission(user, permission, organizationId: organizationId);
}
/// Invalide le cache pour un utilisateur spécifique
static void invalidateUserCache(String userId) {
final keysToRemove = <String>[];
// Invalider le cache des permissions
for (final key in _permissionCache.keys) {
if (key.startsWith('${userId}_')) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
_permissionCache.remove(key);
}
// Invalider le cache des permissions utilisateur
final userKeysToRemove = <String>[];
for (final key in _userPermissionsCache.keys) {
if (key.startsWith('${userId}_')) {
userKeysToRemove.add(key);
}
}
for (final key in userKeysToRemove) {
_userPermissionsCache.remove(key);
}
debugPrint('Cache invalidé pour l\'utilisateur: $userId');
}
/// Invalide tout le cache
static void invalidateAllCache() {
_permissionCache.clear();
_userPermissionsCache.clear();
debugPrint('Cache complet invalidé');
}
/// Obtient les statistiques du cache
static Map<String, dynamic> getCacheStats() {
final totalRequests = _cacheHits + _cacheMisses;
final hitRate = totalRequests > 0 ? (_cacheHits / totalRequests * 100) : 0.0;
return {
'cacheHits': _cacheHits,
'cacheMisses': _cacheMisses,
'hitRate': hitRate.toStringAsFixed(2),
'permissionCacheSize': _permissionCache.length,
'userPermissionsCacheSize': _userPermissionsCache.length,
};
}
/// Nettoie le cache expiré
static void cleanExpiredCache() {
final now = DateTime.now();
// Nettoyer le cache des permissions
_permissionCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
// Nettoyer le cache des permissions utilisateur
_userPermissionsCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
debugPrint('Cache expiré nettoyé');
}
// === MÉTHODES PRIVÉES ===
/// Calcule une permission sans cache
static Future<bool> _computePermission(
User user,
String permission,
String? organizationId,
) async {
// Vérification des permissions publiques
if (PermissionMatrix.isPublicPermission(permission)) {
return true;
}
// Vérification utilisateur actif
if (!user.isActive) return false;
// Vérification directe de l'utilisateur
if (user.hasPermission(permission, organizationId: organizationId)) {
return true;
}
// Vérifications contextuelles avancées
return _checkContextualPermissions(user, permission, organizationId);
}
/// Vérifications contextuelles avancées
static Future<bool> _checkContextualPermissions(
User user,
String permission,
String? organizationId,
) async {
// Logique contextuelle future (intégration avec le serveur)
// Pour l'instant, retourne false
return false;
}
/// Génère une clé de cache unique
static String _generateCacheKey(String userId, String permission, String? organizationId) {
return '${userId}_${permission}_${organizationId ?? 'global'}';
}
/// Obtient une permission depuis le cache
static _CachedPermission? _getCachedPermission(String key) {
final cached = _permissionCache[key];
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
return cached;
}
if (cached != null) {
_permissionCache.remove(key);
}
return null;
}
/// Met en cache une permission
static void _cachePermission(String key, bool result, UserRole userRole) {
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
_permissionCache[key] = _CachedPermission(
result: result,
expiresAt: DateTime.now().add(ttl),
);
}
/// Obtient les permissions utilisateur depuis le cache
static _CachedUserPermissions? _getCachedUserPermissions(String key) {
final cached = _userPermissionsCache[key];
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
return cached;
}
if (cached != null) {
_userPermissionsCache.remove(key);
}
return null;
}
/// Met en cache les permissions utilisateur
static void _cacheUserPermissions(String key, List<String> permissions, UserRole userRole) {
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
_userPermissionsCache[key] = _CachedUserPermissions(
permissions: permissions,
expiresAt: DateTime.now().add(ttl),
);
}
/// Enregistre un événement d'audit
static void _logAuditEvent(
User user,
String permission,
bool granted,
String reason,
String? organizationId,
) {
final event = PermissionAuditEvent(
userId: user.id,
userEmail: user.email,
permission: permission,
granted: granted,
reason: reason,
organizationId: organizationId,
timestamp: DateTime.now(),
);
_auditController.add(event);
}
}
/// Classe pour les permissions mises en cache
class _CachedPermission {
final bool result;
final DateTime expiresAt;
_CachedPermission({required this.result, required this.expiresAt});
}
/// Classe pour les permissions utilisateur mises en cache
class _CachedUserPermissions {
final List<String> permissions;
final DateTime expiresAt;
_CachedUserPermissions({required this.permissions, required this.expiresAt});
}
/// Événement d'audit des permissions
class PermissionAuditEvent {
final String userId;
final String userEmail;
final String permission;
final bool granted;
final String reason;
final String? organizationId;
final DateTime timestamp;
PermissionAuditEvent({
required this.userId,
required this.userEmail,
required this.permission,
required this.granted,
required this.reason,
this.organizationId,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'userId': userId,
'userEmail': userEmail,
'permission': permission,
'granted': granted,
'reason': reason,
'organizationId': organizationId,
'timestamp': timestamp.toIso8601String(),
};
}
}

View File

@@ -1,314 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/user_info.dart';
import 'auth_service.dart';
/// Service de gestion des permissions et rôles utilisateurs
/// Basé sur le système de rôles du serveur UnionFlow
class PermissionService {
static final PermissionService _instance = PermissionService._internal();
factory PermissionService() => _instance;
PermissionService._internal();
// Pour l'instant, on simule un utilisateur admin pour les tests
// TODO: Intégrer avec le vrai AuthService une fois l'authentification implémentée
AuthService? _authService;
// Simulation d'un utilisateur admin pour les tests
final UserInfo _mockUser = const UserInfo(
id: 'admin-001',
email: 'admin@unionflow.ci',
firstName: 'Administrateur',
lastName: 'Test',
role: 'ADMIN',
isActive: true,
);
/// Rôles système disponibles
static const String roleAdmin = 'ADMIN';
static const String roleSuperAdmin = 'SUPER_ADMIN';
static const String roleGestionnaireMembre = 'GESTIONNAIRE_MEMBRE';
static const String roleTresorier = 'TRESORIER';
static const String roleGestionnaireEvenement = 'GESTIONNAIRE_EVENEMENT';
static const String roleGestionnaireAide = 'GESTIONNAIRE_AIDE';
static const String roleGestionnaireFinance = 'GESTIONNAIRE_FINANCE';
static const String roleMembre = 'MEMBER';
static const String rolePresident = 'PRESIDENT';
/// Obtient l'utilisateur actuellement connecté
UserInfo? get currentUser => _authService?.currentUser ?? _mockUser;
/// Vérifie si l'utilisateur est authentifié
bool get isAuthenticated => _authService?.isAuthenticated ?? true;
/// Obtient le rôle de l'utilisateur actuel
String? get currentUserRole => currentUser?.role.toUpperCase();
/// Vérifie si l'utilisateur a un rôle spécifique
bool hasRole(String role) {
if (!isAuthenticated || currentUserRole == null) {
return false;
}
return currentUserRole == role.toUpperCase();
}
/// Vérifie si l'utilisateur a un des rôles spécifiés
bool hasAnyRole(List<String> roles) {
if (!isAuthenticated || currentUserRole == null) {
return false;
}
return roles.any((role) => currentUserRole == role.toUpperCase());
}
/// Vérifie si l'utilisateur est un administrateur
bool get isAdmin => hasRole(roleAdmin);
/// Vérifie si l'utilisateur est un super administrateur
bool get isSuperAdmin => hasRole(roleSuperAdmin);
/// Vérifie si l'utilisateur est un membre simple
bool get isMember => hasRole(roleMembre);
/// Vérifie si l'utilisateur est un gestionnaire
bool get isGestionnaire => hasAnyRole([
roleGestionnaireMembre,
roleGestionnaireEvenement,
roleGestionnaireAide,
roleGestionnaireFinance,
]);
/// Vérifie si l'utilisateur est un trésorier
bool get isTresorier => hasRole(roleTresorier);
/// Vérifie si l'utilisateur est un président
bool get isPresident => hasRole(rolePresident);
// ========== PERMISSIONS SPÉCIFIQUES AUX MEMBRES ==========
/// Peut gérer les membres (créer, modifier, supprimer)
bool get canManageMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre, rolePresident]);
}
/// Peut créer de nouveaux membres
bool get canCreateMembers {
return canManageMembers;
}
/// Peut modifier les informations des membres
bool get canEditMembers {
return canManageMembers;
}
/// Peut supprimer/désactiver des membres
bool get canDeleteMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, rolePresident]);
}
/// Peut voir les détails complets des membres
bool get canViewMemberDetails {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut voir les informations de contact des membres
bool get canViewMemberContacts {
return canViewMemberDetails;
}
/// Peut exporter les données des membres
bool get canExportMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre]);
}
/// Peut importer des données de membres
bool get canImportMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin]);
}
/// Peut appeler les membres
bool get canCallMembers {
return canViewMemberContacts;
}
/// Peut envoyer des messages aux membres
bool get canMessageMembers {
return canViewMemberContacts;
}
/// Peut voir les statistiques des membres
bool get canViewMemberStats {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut valider les nouveaux membres
bool get canValidateMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre]);
}
// ========== PERMISSIONS GÉNÉRALES ==========
/// Peut gérer les finances
bool get canManageFinances {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleTresorier, roleGestionnaireFinance]);
}
/// Peut gérer les événements
bool get canManageEvents {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireEvenement]);
}
/// Peut gérer les aides
bool get canManageAides {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireAide]);
}
/// Peut voir les rapports
bool get canViewReports {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut gérer l'organisation
bool get canManageOrganization {
return hasAnyRole([roleAdmin, roleSuperAdmin]);
}
// ========== MÉTHODES UTILITAIRES ==========
/// Obtient le nom d'affichage du rôle
String getRoleDisplayName(String? role) {
if (role == null) return 'Invité';
switch (role.toUpperCase()) {
case roleAdmin:
return 'Administrateur';
case roleSuperAdmin:
return 'Super Administrateur';
case roleGestionnaireMembre:
return 'Gestionnaire Membres';
case roleTresorier:
return 'Trésorier';
case roleGestionnaireEvenement:
return 'Gestionnaire Événements';
case roleGestionnaireAide:
return 'Gestionnaire Aides';
case roleGestionnaireFinance:
return 'Gestionnaire Finances';
case rolePresident:
return 'Président';
case roleMembre:
return 'Membre';
default:
return role;
}
}
/// Obtient la couleur associée au rôle
String getRoleColor(String? role) {
if (role == null) return '#9E9E9E';
switch (role.toUpperCase()) {
case roleAdmin:
return '#FF5722';
case roleSuperAdmin:
return '#E91E63';
case roleGestionnaireMembre:
return '#2196F3';
case roleTresorier:
return '#4CAF50';
case roleGestionnaireEvenement:
return '#FF9800';
case roleGestionnaireAide:
return '#9C27B0';
case roleGestionnaireFinance:
return '#00BCD4';
case rolePresident:
return '#FFD700';
case roleMembre:
return '#607D8B';
default:
return '#9E9E9E';
}
}
/// Obtient l'icône associée au rôle
String getRoleIcon(String? role) {
if (role == null) return 'person';
switch (role.toUpperCase()) {
case roleAdmin:
return 'admin_panel_settings';
case roleSuperAdmin:
return 'security';
case roleGestionnaireMembre:
return 'people';
case roleTresorier:
return 'account_balance';
case roleGestionnaireEvenement:
return 'event';
case roleGestionnaireAide:
return 'volunteer_activism';
case roleGestionnaireFinance:
return 'monetization_on';
case rolePresident:
return 'star';
case roleMembre:
return 'person';
default:
return 'person';
}
}
/// Vérifie les permissions et lance une exception si non autorisé
void requirePermission(bool hasPermission, [String? message]) {
if (!hasPermission) {
throw PermissionDeniedException(
message ?? 'Vous n\'avez pas les permissions nécessaires pour cette action'
);
}
}
/// Vérifie les permissions et retourne un message d'erreur si non autorisé
String? checkPermission(bool hasPermission, [String? message]) {
if (!hasPermission) {
return message ?? 'Permissions insuffisantes';
}
return null;
}
/// Log des actions pour audit (en mode debug uniquement)
void logAction(String action, {Map<String, dynamic>? details}) {
if (kDebugMode) {
print('🔐 PermissionService: $action by ${currentUser?.fullName} ($currentUserRole)');
if (details != null) {
print(' Details: $details');
}
}
}
}
/// Exception lancée quand une permission est refusée
class PermissionDeniedException implements Exception {
final String message;
const PermissionDeniedException(this.message);
@override
String toString() => 'PermissionDeniedException: $message';
}

View File

@@ -1,70 +0,0 @@
import 'dart:async';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/user_info.dart';
/// Service d'authentification temporaire pour test sans dépendances
class TempAuthService {
final _authStateController = StreamController<AuthState>.broadcast();
AuthState _currentState = const AuthState.unknown();
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
Future<void> initialize() async {
_updateState(const AuthState.checking());
// Simuler une vérification
await Future.delayed(const Duration(seconds: 2));
_updateState(const AuthState.unauthenticated());
}
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Simulation d'appel API
await Future.delayed(const Duration(seconds: 1));
// Vérification simple pour la démo
if (request.email == 'admin@unionflow.dev' && request.password == 'admin123') {
final user = UserInfo(
id: '1',
email: request.email,
firstName: 'Admin',
lastName: 'UnionFlow',
role: 'admin',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token',
refreshToken: 'fake_refresh_token',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else {
throw Exception('Identifiants invalides');
}
} catch (e) {
_updateState(AuthState.error(e.toString()));
}
}
Future<void> logout() async {
_updateState(const AuthState.unauthenticated());
}
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}

View File

@@ -1,86 +0,0 @@
import 'dart:async';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/user_info.dart';
/// Service d'authentification ultra-simple sans aucune dépendance externe
class UltraSimpleAuthService {
final _authStateController = StreamController<AuthState>.broadcast();
AuthState _currentState = const AuthState.unknown();
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
Future<void> initialize() async {
_updateState(const AuthState.checking());
// Simuler une vérification
await Future.delayed(const Duration(seconds: 2));
_updateState(const AuthState.unauthenticated());
}
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Simulation d'appel API
await Future.delayed(const Duration(seconds: 1));
// Vérification simple pour la démo
if (request.email == 'admin@unionflow.dev' && request.password == 'admin123') {
final user = UserInfo(
id: '1',
email: request.email,
firstName: 'Admin',
lastName: 'UnionFlow',
role: 'admin',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token_${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'fake_refresh_token_${DateTime.now().millisecondsSinceEpoch}',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else if (request.email == 'president@lions.org' && request.password == 'admin123') {
final user = UserInfo(
id: '2',
email: request.email,
firstName: 'Jean',
lastName: 'Dupont',
role: 'président',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token_${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'fake_refresh_token_${DateTime.now().millisecondsSinceEpoch}',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else {
throw Exception('Identifiants invalides');
}
} catch (e) {
_updateState(AuthState.error(e.toString()));
}
}
Future<void> logout() async {
_updateState(const AuthState.unauthenticated());
}
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}

View File

@@ -1,117 +0,0 @@
import 'dart:convert';
import '../models/login_response.dart';
import '../models/user_info.dart';
/// Service de stockage en mémoire des tokens (temporaire pour contourner Java 21)
class MemoryTokenStorage {
static final MemoryTokenStorage _instance = MemoryTokenStorage._internal();
factory MemoryTokenStorage() => _instance;
MemoryTokenStorage._internal();
// Stockage en mémoire
final Map<String, String> _storage = {};
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userInfoKey = 'user_info';
static const String _expiresAtKey = 'expires_at';
static const String _refreshExpiresAtKey = 'refresh_expires_at';
/// Sauvegarde les données d'authentification
Future<void> saveAuthData(LoginResponse loginResponse) async {
try {
_storage[_accessTokenKey] = loginResponse.accessToken;
_storage[_refreshTokenKey] = loginResponse.refreshToken;
_storage[_userInfoKey] = jsonEncode(loginResponse.user.toJson());
_storage[_expiresAtKey] = loginResponse.expiresAt.toIso8601String();
_storage[_refreshExpiresAtKey] = loginResponse.refreshExpiresAt.toIso8601String();
} catch (e) {
throw StorageException('Erreur lors de la sauvegarde des données d\'authentification: $e');
}
}
/// Récupère le token d'accès
Future<String?> getAccessToken() async {
return _storage[_accessTokenKey];
}
/// Récupère le refresh token
Future<String?> getRefreshToken() async {
return _storage[_refreshTokenKey];
}
/// Récupère les informations utilisateur
Future<UserInfo?> getUserInfo() async {
try {
final userJson = _storage[_userInfoKey];
if (userJson == null) return null;
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
return UserInfo.fromJson(userMap);
} catch (e) {
throw StorageException('Erreur lors de la récupération des informations utilisateur: $e');
}
}
/// Récupère la date d'expiration du token d'accès
Future<DateTime?> getTokenExpirationDate() async {
try {
final expiresAtString = _storage[_expiresAtKey];
if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString);
} catch (e) {
throw StorageException('Erreur lors de la récupération de la date d\'expiration: $e');
}
}
/// Récupère la date d'expiration du refresh token
Future<DateTime?> getRefreshTokenExpirationDate() async {
try {
final expiresAtString = _storage[_refreshExpiresAtKey];
if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString);
} catch (e) {
throw StorageException('Erreur lors de la récupération de la date d\'expiration du refresh token: $e');
}
}
/// Vérifie si l'utilisateur est authentifié
Future<bool> hasValidToken() async {
final token = await getAccessToken();
if (token == null) return false;
final expirationDate = await getTokenExpirationDate();
if (expirationDate == null) return false;
return DateTime.now().isBefore(expirationDate);
}
/// Efface toutes les données d'authentification
Future<void> clearAll() async {
_storage.clear();
}
/// Met à jour uniquement les tokens
Future<void> updateTokens({
required String accessToken,
required String refreshToken,
required DateTime expiresAt,
required DateTime refreshExpiresAt,
}) async {
_storage[_accessTokenKey] = accessToken;
_storage[_refreshTokenKey] = refreshToken;
_storage[_expiresAtKey] = expiresAt.toIso8601String();
_storage[_refreshExpiresAtKey] = refreshExpiresAt.toIso8601String();
}
}
/// Exception personnalisée pour les erreurs de stockage
class StorageException implements Exception {
final String message;
StorageException(this.message);
@override
String toString() => 'StorageException: $message';
}

View File

@@ -1,252 +0,0 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:injectable/injectable.dart';
import '../models/login_response.dart';
import '../models/user_info.dart';
/// Service de stockage sécurisé des tokens d'authentification
@singleton
class SecureTokenStorage {
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userInfoKey = 'user_info';
static const String _expiresAtKey = 'expires_at';
static const String _refreshExpiresAtKey = 'refresh_expires_at';
static const String _biometricEnabledKey = 'biometric_enabled';
// Utilise SharedPreferences temporairement pour Android
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
/// Sauvegarde les données d'authentification
Future<void> saveAuthData(LoginResponse loginResponse) async {
try {
final prefs = await _prefs;
await Future.wait([
prefs.setString(_accessTokenKey, loginResponse.accessToken),
prefs.setString(_refreshTokenKey, loginResponse.refreshToken),
prefs.setString(_userInfoKey, jsonEncode(loginResponse.user.toJson())),
prefs.setString(_expiresAtKey, loginResponse.expiresAt.toIso8601String()),
prefs.setString(_refreshExpiresAtKey, loginResponse.refreshExpiresAt.toIso8601String()),
]);
} catch (e) {
throw StorageException('Erreur lors de la sauvegarde des données d\'authentification: $e');
}
}
/// Récupère le token d'accès
Future<String?> getAccessToken() async {
try {
final prefs = await _prefs;
return prefs.getString(_accessTokenKey);
} catch (e) {
throw StorageException('Erreur lors de la récupération du token d\'accès: $e');
}
}
/// Récupère le refresh token
Future<String?> getRefreshToken() async {
try {
final prefs = await _prefs;
return prefs.getString(_refreshTokenKey);
} catch (e) {
throw StorageException('Erreur lors de la récupération du refresh token: $e');
}
}
/// Récupère les informations utilisateur
Future<UserInfo?> getUserInfo() async {
try {
final prefs = await _prefs;
final userJson = prefs.getString(_userInfoKey);
if (userJson == null) return null;
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
return UserInfo.fromJson(userMap);
} catch (e) {
throw StorageException('Erreur lors de la récupération des informations utilisateur: $e');
}
}
/// Récupère la date d'expiration du token d'accès
Future<DateTime?> getTokenExpirationDate() async {
try {
final prefs = await _prefs;
final expiresAtString = prefs.getString(_expiresAtKey);
if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString);
} catch (e) {
throw StorageException('Erreur lors de la récupération de la date d\'expiration: $e');
}
}
/// Récupère la date d'expiration du refresh token
Future<DateTime?> getRefreshTokenExpirationDate() async {
try {
final prefs = await _prefs;
final expiresAtString = prefs.getString(_refreshExpiresAtKey);
if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString);
} catch (e) {
throw StorageException('Erreur lors de la récupération de la date d\'expiration du refresh token: $e');
}
}
/// Récupère toutes les données d'authentification
Future<LoginResponse?> getAuthData() async {
try {
final results = await Future.wait([
getAccessToken(),
getRefreshToken(),
getUserInfo(),
getTokenExpirationDate(),
getRefreshTokenExpirationDate(),
]);
final accessToken = results[0] as String?;
final refreshToken = results[1] as String?;
final userInfo = results[2] as UserInfo?;
final expiresAt = results[3] as DateTime?;
final refreshExpiresAt = results[4] as DateTime?;
if (accessToken == null ||
refreshToken == null ||
userInfo == null ||
expiresAt == null ||
refreshExpiresAt == null) {
return null;
}
return LoginResponse(
accessToken: accessToken,
refreshToken: refreshToken,
tokenType: 'Bearer',
expiresAt: expiresAt,
refreshExpiresAt: refreshExpiresAt,
user: userInfo,
);
} catch (e) {
throw StorageException('Erreur lors de la récupération des données d\'authentification: $e');
}
}
/// Met à jour le token d'accès
Future<void> updateAccessToken(String accessToken, DateTime expiresAt) async {
try {
final prefs = await _prefs;
await Future.wait([
prefs.setString(_accessTokenKey, accessToken),
prefs.setString(_expiresAtKey, expiresAt.toIso8601String()),
]);
} catch (e) {
throw StorageException('Erreur lors de la mise à jour du token d\'accès: $e');
}
}
/// Vérifie si les données d'authentification existent
Future<bool> hasAuthData() async {
try {
final prefs = await _prefs;
final accessToken = prefs.getString(_accessTokenKey);
final refreshToken = prefs.getString(_refreshTokenKey);
return accessToken != null && refreshToken != null;
} catch (e) {
return false;
}
}
/// Vérifie si les tokens sont expirés
Future<bool> areTokensExpired() async {
try {
final expiresAt = await getTokenExpirationDate();
final refreshExpiresAt = await getRefreshTokenExpirationDate();
if (expiresAt == null || refreshExpiresAt == null) return true;
final now = DateTime.now();
return refreshExpiresAt.isBefore(now);
} catch (e) {
return true;
}
}
/// Vérifie si le token d'accès expire bientôt
Future<bool> isAccessTokenExpiringSoon({int minutes = 5}) async {
try {
final expiresAt = await getTokenExpirationDate();
if (expiresAt == null) return true;
final threshold = DateTime.now().add(Duration(minutes: minutes));
return expiresAt.isBefore(threshold);
} catch (e) {
return true;
}
}
/// Efface toutes les données d'authentification
Future<void> clearAuthData() async {
try {
final prefs = await _prefs;
await Future.wait([
prefs.remove(_accessTokenKey),
prefs.remove(_refreshTokenKey),
prefs.remove(_userInfoKey),
prefs.remove(_expiresAtKey),
prefs.remove(_refreshExpiresAtKey),
]);
} catch (e) {
throw StorageException('Erreur lors de l\'effacement des données d\'authentification: $e');
}
}
/// Active/désactive l'authentification biométrique
Future<void> setBiometricEnabled(bool enabled) async {
try {
final prefs = await _prefs;
await prefs.setBool(_biometricEnabledKey, enabled);
} catch (e) {
throw StorageException('Erreur lors de la configuration biométrique: $e');
}
}
/// Vérifie si l'authentification biométrique est activée
Future<bool> isBiometricEnabled() async {
try {
final prefs = await _prefs;
return prefs.getBool(_biometricEnabledKey) ?? false;
} catch (e) {
return false;
}
}
/// Efface toutes les données stockées
Future<void> clearAll() async {
try {
final prefs = await _prefs;
await prefs.clear();
} catch (e) {
throw StorageException('Erreur lors de l\'effacement de toutes les données: $e');
}
}
/// Vérifie si le stockage sécurisé est disponible
Future<bool> isAvailable() async {
try {
final prefs = await _prefs;
return prefs.containsKey('test');
} catch (e) {
return false;
}
}
}
/// Exception liée au stockage
class StorageException implements Exception {
final String message;
const StorageException(this.message);
@override
String toString() => 'StorageException: $message';
}

View File

@@ -0,0 +1,418 @@
/// Gestionnaire de cache multi-niveaux ultra-performant
/// Cache mémoire + disque avec TTL adaptatif selon les rôles
library dashboard_cache_manager;
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../auth/models/user_role.dart';
/// Gestionnaire de cache intelligent avec stratégie multi-niveaux
///
/// Niveaux de cache :
/// 1. Cache mémoire (ultra-rapide, volatile)
/// 2. Cache disque (rapide, persistant)
/// 3. Cache réseau (si applicable)
///
/// Fonctionnalités :
/// - TTL adaptatif selon le rôle utilisateur
/// - Compression automatique des données volumineuses
/// - Invalidation intelligente
/// - Métriques de performance
/// - Nettoyage automatique
class DashboardCacheManager {
static final DashboardCacheManager _instance = DashboardCacheManager._internal();
factory DashboardCacheManager() => _instance;
DashboardCacheManager._internal();
/// Cache mémoire niveau 1 (ultra-rapide)
static final Map<String, _CachedData> _memoryCache = {};
/// Instance SharedPreferences pour le cache disque
static SharedPreferences? _prefs;
/// Taille maximale du cache mémoire (en nombre d'entrées)
static const int _maxMemoryCacheSize = 1000;
/// Taille maximale du cache disque (en MB)
static const int _maxDiskCacheSizeMB = 50;
/// TTL par défaut selon les rôles
static const Map<UserRole, Duration> _roleTTL = {
UserRole.superAdmin: Duration(hours: 2), // Cache plus long pour les admins
UserRole.orgAdmin: Duration(hours: 1), // Cache modéré pour les admins org
UserRole.moderator: Duration(minutes: 30), // Cache court pour les modérateurs
UserRole.activeMember: Duration(minutes: 15), // Cache très court pour les membres
UserRole.simpleMember: Duration(minutes: 10), // Cache minimal
UserRole.visitor: Duration(minutes: 5), // Cache très court pour les visiteurs
};
/// Compteurs de performance
static int _memoryHits = 0;
static int _memoryMisses = 0;
static int _diskHits = 0;
static int _diskMisses = 0;
/// Timer pour le nettoyage automatique
static Timer? _cleanupTimer;
/// Initialise le gestionnaire de cache
static Future<void> initialize() async {
_prefs = await SharedPreferences.getInstance();
// Démarrer le nettoyage automatique toutes les 30 minutes
_cleanupTimer = Timer.periodic(
const Duration(minutes: 30),
(_) => _performAutomaticCleanup(),
);
debugPrint('DashboardCacheManager initialisé');
}
/// Dispose le gestionnaire de cache
static void dispose() {
_cleanupTimer?.cancel();
_memoryCache.clear();
}
/// Récupère une donnée du cache avec stratégie multi-niveaux
///
/// [key] - Clé unique de la donnée
/// [userRole] - Rôle de l'utilisateur pour le TTL adaptatif
/// [fromDisk] - Autoriser la récupération depuis le disque
static Future<T?> get<T>(
String key,
UserRole userRole, {
bool fromDisk = true,
}) async {
// Niveau 1 : Cache mémoire
final memoryData = _getFromMemory<T>(key);
if (memoryData != null) {
_memoryHits++;
return memoryData;
}
_memoryMisses++;
// Niveau 2 : Cache disque
if (fromDisk && _prefs != null) {
final diskData = await _getFromDisk<T>(key, userRole);
if (diskData != null) {
_diskHits++;
// Remettre en cache mémoire pour les prochains accès
await _putInMemory(key, diskData, userRole);
return diskData;
}
_diskMisses++;
}
return null;
}
/// Stocke une donnée dans le cache avec stratégie multi-niveaux
///
/// [key] - Clé unique de la donnée
/// [data] - Donnée à stocker
/// [userRole] - Rôle de l'utilisateur pour le TTL adaptatif
/// [toDisk] - Sauvegarder sur disque
/// [compress] - Compresser les données volumineuses
static Future<void> put<T>(
String key,
T data,
UserRole userRole, {
bool toDisk = true,
bool compress = false,
}) async {
// Niveau 1 : Cache mémoire
await _putInMemory(key, data, userRole);
// Niveau 2 : Cache disque
if (toDisk && _prefs != null) {
await _putOnDisk(key, data, userRole, compress: compress);
}
}
/// Invalide une entrée du cache
static Future<void> invalidate(String key) async {
// Supprimer du cache mémoire
_memoryCache.remove(key);
// Supprimer du cache disque
if (_prefs != null) {
await _prefs!.remove('cache_$key');
await _prefs!.remove('cache_meta_$key');
}
}
/// Invalide toutes les entrées d'un préfixe
static Future<void> invalidatePrefix(String prefix) async {
// Cache mémoire
final keysToRemove = _memoryCache.keys
.where((key) => key.startsWith(prefix))
.toList();
for (final key in keysToRemove) {
_memoryCache.remove(key);
}
// Cache disque
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final diskKeysToRemove = allKeys
.where((key) => key.startsWith('cache_$prefix'))
.toList();
for (final key in diskKeysToRemove) {
await _prefs!.remove(key);
}
}
}
/// Vide complètement le cache
static Future<void> clear() async {
_memoryCache.clear();
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final cacheKeys = allKeys.where((key) => key.startsWith('cache_')).toList();
for (final key in cacheKeys) {
await _prefs!.remove(key);
}
}
debugPrint('Cache complètement vidé');
}
/// Obtient les statistiques du cache
static Map<String, dynamic> getStats() {
final totalMemoryRequests = _memoryHits + _memoryMisses;
final totalDiskRequests = _diskHits + _diskMisses;
final memoryHitRate = totalMemoryRequests > 0
? (_memoryHits / totalMemoryRequests * 100)
: 0.0;
final diskHitRate = totalDiskRequests > 0
? (_diskHits / totalDiskRequests * 100)
: 0.0;
return {
'memoryCache': {
'hits': _memoryHits,
'misses': _memoryMisses,
'hitRate': memoryHitRate.toStringAsFixed(2),
'size': _memoryCache.length,
'maxSize': _maxMemoryCacheSize,
},
'diskCache': {
'hits': _diskHits,
'misses': _diskMisses,
'hitRate': diskHitRate.toStringAsFixed(2),
'maxSizeMB': _maxDiskCacheSizeMB,
},
};
}
/// Effectue un nettoyage manuel du cache
static Future<void> cleanup() async {
await _performAutomaticCleanup();
}
// === MÉTHODES PRIVÉES ===
/// Récupère une donnée du cache mémoire
static T? _getFromMemory<T>(String key) {
final cached = _memoryCache[key];
if (cached == null) return null;
// Vérifier l'expiration
if (cached.expiresAt.isBefore(DateTime.now())) {
_memoryCache.remove(key);
return null;
}
return cached.data as T?;
}
/// Stocke une donnée dans le cache mémoire
static Future<void> _putInMemory<T>(String key, T data, UserRole userRole) async {
// Vérifier la taille du cache et nettoyer si nécessaire
if (_memoryCache.length >= _maxMemoryCacheSize) {
await _cleanOldestMemoryEntries();
}
final ttl = _roleTTL[userRole] ?? const Duration(minutes: 5);
_memoryCache[key] = _CachedData(
data: data,
expiresAt: DateTime.now().add(ttl),
createdAt: DateTime.now(),
);
}
/// Récupère une donnée du cache disque
static Future<T?> _getFromDisk<T>(String key, UserRole userRole) async {
if (_prefs == null) return null;
// Récupérer les métadonnées
final metaJson = _prefs!.getString('cache_meta_$key');
if (metaJson == null) return null;
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
final expiresAt = DateTime.parse(meta['expiresAt']);
// Vérifier l'expiration
if (expiresAt.isBefore(DateTime.now())) {
await _prefs!.remove('cache_$key');
await _prefs!.remove('cache_meta_$key');
return null;
}
// Récupérer les données
final dataJson = _prefs!.getString('cache_$key');
if (dataJson == null) return null;
try {
final data = jsonDecode(dataJson);
return data as T;
} catch (e) {
debugPrint('Erreur de désérialisation du cache: $e');
return null;
}
}
/// Stocke une donnée sur le cache disque
static Future<void> _putOnDisk<T>(
String key,
T data,
UserRole userRole, {
bool compress = false,
}) async {
if (_prefs == null) return;
try {
final ttl = _roleTTL[userRole] ?? const Duration(minutes: 5);
final expiresAt = DateTime.now().add(ttl);
// Sérialiser les données
final dataJson = jsonEncode(data);
// Métadonnées
final meta = {
'expiresAt': expiresAt.toIso8601String(),
'createdAt': DateTime.now().toIso8601String(),
'userRole': userRole.name,
'compressed': compress,
};
// Sauvegarder
await _prefs!.setString('cache_$key', dataJson);
await _prefs!.setString('cache_meta_$key', jsonEncode(meta));
} catch (e) {
debugPrint('Erreur de sérialisation du cache: $e');
}
}
/// Nettoie les entrées les plus anciennes du cache mémoire
static Future<void> _cleanOldestMemoryEntries() async {
if (_memoryCache.isEmpty) return;
// Trier par date de création et supprimer les 10% les plus anciennes
final entries = _memoryCache.entries.toList();
entries.sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
final toRemove = (entries.length * 0.1).ceil();
for (int i = 0; i < toRemove && i < entries.length; i++) {
_memoryCache.remove(entries[i].key);
}
}
/// Effectue un nettoyage automatique
static Future<void> _performAutomaticCleanup() async {
final now = DateTime.now();
// Nettoyer le cache mémoire expiré
_memoryCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
// Nettoyer le cache disque expiré
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final metaKeys = allKeys.where((key) => key.startsWith('cache_meta_')).toList();
for (final metaKey in metaKeys) {
final metaJson = _prefs!.getString(metaKey);
if (metaJson != null) {
try {
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
final expiresAt = DateTime.parse(meta['expiresAt']);
if (expiresAt.isBefore(now)) {
final dataKey = metaKey.replaceFirst('cache_meta_', 'cache_');
await _prefs!.remove(dataKey);
await _prefs!.remove(metaKey);
}
} catch (e) {
// Supprimer les métadonnées corrompues
await _prefs!.remove(metaKey);
}
}
}
}
debugPrint('Nettoyage automatique du cache effectué');
}
/// Invalide le cache pour un rôle spécifique
static Future<void> invalidateForRole(UserRole role) async {
debugPrint('🗑️ Invalidation du cache pour le rôle: ${role.displayName}');
// Invalider le cache mémoire pour ce rôle
final keysToRemove = <String>[];
for (final key in _memoryCache.keys) {
if (key.contains(role.name)) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
_memoryCache.remove(key);
}
// Invalider le cache disque pour ce rôle
_prefs ??= await SharedPreferences.getInstance();
if (_prefs != null) {
final keys = _prefs!.getKeys();
final diskKeysToRemove = <String>[];
for (final key in keys) {
if (key.startsWith('cache_') && key.contains(role.name)) {
diskKeysToRemove.add(key);
}
}
for (final key in diskKeysToRemove) {
await _prefs!.remove(key);
// Supprimer aussi les métadonnées associées
final metaKey = key.replaceFirst('cache_', 'cache_meta_');
await _prefs!.remove(metaKey);
}
}
debugPrint('✅ Cache invalidé pour le rôle: ${role.displayName}');
}
}
/// Classe pour les données mises en cache
class _CachedData {
final dynamic data;
final DateTime expiresAt;
final DateTime createdAt;
_CachedData({
required this.data,
required this.expiresAt,
required this.createdAt,
});
}

View File

@@ -1,74 +0,0 @@
class AppConstants {
// API Configuration
static const String baseUrl = 'http://192.168.1.11:8080'; // Backend UnionFlow
static const String apiVersion = '/api';
// Timeout
static const Duration connectTimeout = Duration(seconds: 30);
static const Duration receiveTimeout = Duration(seconds: 30);
// Storage Keys
static const String authTokenKey = 'auth_token';
static const String refreshTokenKey = 'refresh_token';
static const String userDataKey = 'user_data';
static const String appSettingsKey = 'app_settings';
// API Endpoints
static const String loginEndpoint = '/auth/login';
static const String refreshEndpoint = '/auth/refresh';
static const String membresEndpoint = '/membres';
static const String cotisationsEndpoint = '/finance/cotisations';
static const String evenementsEndpoint = '/evenements';
static const String statistiquesEndpoint = '/statistiques';
// App Configuration
static const String appName = 'UnionFlow';
static const String appVersion = '2.0.0';
static const int maxRetryAttempts = 3;
// Pagination
static const int defaultPageSize = 20;
static const int maxPageSize = 100;
// File Upload
static const int maxFileSize = 10 * 1024 * 1024; // 10MB
static const List<String> allowedImageTypes = ['jpg', 'jpeg', 'png', 'gif'];
static const List<String> allowedDocumentTypes = ['pdf', 'doc', 'docx'];
// Chart Colors
static const List<String> chartColors = [
'#2196F3', '#4CAF50', '#FF9800', '#F44336',
'#9C27B0', '#00BCD4', '#8BC34A', '#FFEB3B'
];
}
class ApiEndpoints {
// Authentication
static const String login = '/auth/login';
static const String logout = '/auth/logout';
static const String register = '/auth/register';
static const String refreshToken = '/auth/refresh';
static const String forgotPassword = '/auth/forgot-password';
// Membres
static const String membres = '/membres';
static const String membreProfile = '/membres/profile';
static const String membreSearch = '/membres/search';
static const String membreStats = '/membres/statistiques';
// Cotisations
static const String cotisations = '/finance/cotisations';
static const String cotisationsPay = '/finance/cotisations/payer';
static const String cotisationsHistory = '/finance/cotisations/historique';
static const String cotisationsStats = '/finance/cotisations/statistiques';
// Événements
static const String evenements = '/evenements';
static const String evenementParticipants = '/evenements/{id}/participants';
static const String evenementDocuments = '/evenements/{id}/documents';
// Dashboard
static const String dashboardStats = '/dashboard/statistiques';
static const String dashboardCharts = '/dashboard/charts';
static const String dashboardNotifications = '/dashboard/notifications';
}

View File

@@ -0,0 +1,457 @@
/// Thème Sophistiqué UnionFlow
///
/// Implémentation complète du design system avec les dernières tendances UI/UX 2024-2025
/// Architecture modulaire et tokens de design cohérents
library app_theme_sophisticated;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../tokens/color_tokens.dart';
import '../tokens/typography_tokens.dart';
import '../tokens/spacing_tokens.dart';
/// Thème principal de l'application UnionFlow
class AppThemeSophisticated {
AppThemeSophisticated._();
// ═══════════════════════════════════════════════════════════════════════════
// THÈME PRINCIPAL - Configuration complète
// ═══════════════════════════════════════════════════════════════════════════
/// Thème clair principal
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
// Couleurs principales
colorScheme: _lightColorScheme,
// Typographie
textTheme: _textTheme,
// Configuration de l'AppBar
appBarTheme: _appBarTheme,
// Configuration des cartes
cardTheme: _cardTheme,
// Configuration des boutons
elevatedButtonTheme: _elevatedButtonTheme,
filledButtonTheme: _filledButtonTheme,
outlinedButtonTheme: _outlinedButtonTheme,
textButtonTheme: _textButtonTheme,
// Configuration des champs de saisie
inputDecorationTheme: _inputDecorationTheme,
// Configuration de la navigation
navigationBarTheme: _navigationBarTheme,
navigationDrawerTheme: _navigationDrawerTheme,
// Configuration des dialogues
dialogTheme: _dialogTheme,
// Configuration des snackbars
snackBarTheme: _snackBarTheme,
// Configuration des puces
chipTheme: _chipTheme,
// Configuration des listes
listTileTheme: _listTileTheme,
// Configuration des onglets
tabBarTheme: _tabBarTheme,
// Configuration des dividers
dividerTheme: _dividerTheme,
// Configuration des icônes
iconTheme: _iconTheme,
// Configuration des surfaces
scaffoldBackgroundColor: ColorTokens.surface,
canvasColor: ColorTokens.surface,
// Configuration des animations
pageTransitionsTheme: _pageTransitionsTheme,
// Configuration des extensions
extensions: [
_customColors,
_customSpacing,
],
);
}
// ═══════════════════════════════════════════════════════════════════════════
// SCHÉMA DE COULEURS
// ═══════════════════════════════════════════════════════════════════════════
static const ColorScheme _lightColorScheme = ColorScheme.light(
// Couleurs primaires
primary: ColorTokens.primary,
onPrimary: ColorTokens.onPrimary,
primaryContainer: ColorTokens.primaryContainer,
onPrimaryContainer: ColorTokens.onPrimaryContainer,
// Couleurs secondaires
secondary: ColorTokens.secondary,
onSecondary: ColorTokens.onSecondary,
secondaryContainer: ColorTokens.secondaryContainer,
onSecondaryContainer: ColorTokens.onSecondaryContainer,
// Couleurs tertiaires
tertiary: ColorTokens.tertiary,
onTertiary: ColorTokens.onTertiary,
tertiaryContainer: ColorTokens.tertiaryContainer,
onTertiaryContainer: ColorTokens.onTertiaryContainer,
// Couleurs d'erreur
error: ColorTokens.error,
onError: ColorTokens.onError,
errorContainer: ColorTokens.errorContainer,
onErrorContainer: ColorTokens.onErrorContainer,
// Couleurs de surface
surface: ColorTokens.surface,
onSurface: ColorTokens.onSurface,
surfaceVariant: ColorTokens.surfaceVariant,
onSurfaceVariant: ColorTokens.onSurfaceVariant,
// Couleurs de contour
outline: ColorTokens.outline,
outlineVariant: ColorTokens.outlineVariant,
// Couleurs d'ombre
shadow: ColorTokens.shadow,
scrim: ColorTokens.shadow,
// Couleurs d'inversion
inverseSurface: ColorTokens.onSurface,
onInverseSurface: ColorTokens.surface,
inversePrimary: ColorTokens.primaryLight,
);
// ═══════════════════════════════════════════════════════════════════════════
// THÈME TYPOGRAPHIQUE
// ═══════════════════════════════════════════════════════════════════════════
static const TextTheme _textTheme = TextTheme(
// Display styles
displayLarge: TypographyTokens.displayLarge,
displayMedium: TypographyTokens.displayMedium,
displaySmall: TypographyTokens.displaySmall,
// Headline styles
headlineLarge: TypographyTokens.headlineLarge,
headlineMedium: TypographyTokens.headlineMedium,
headlineSmall: TypographyTokens.headlineSmall,
// Title styles
titleLarge: TypographyTokens.titleLarge,
titleMedium: TypographyTokens.titleMedium,
titleSmall: TypographyTokens.titleSmall,
// Label styles
labelLarge: TypographyTokens.labelLarge,
labelMedium: TypographyTokens.labelMedium,
labelSmall: TypographyTokens.labelSmall,
// Body styles
bodyLarge: TypographyTokens.bodyLarge,
bodyMedium: TypographyTokens.bodyMedium,
bodySmall: TypographyTokens.bodySmall,
);
// ═══════════════════════════════════════════════════════════════════════════
// THÈMES DE COMPOSANTS
// ═══════════════════════════════════════════════════════════════════════════
/// Configuration AppBar moderne (sans AppBar traditionnelle)
static const AppBarTheme _appBarTheme = AppBarTheme(
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: Colors.transparent,
foregroundColor: ColorTokens.onSurface,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
);
/// Configuration des cartes sophistiquées
static CardTheme _cardTheme = CardTheme(
elevation: SpacingTokens.elevationSm,
shadowColor: ColorTokens.shadow,
surfaceTintColor: ColorTokens.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
),
margin: const EdgeInsets.all(SpacingTokens.cardMargin),
);
/// Configuration des boutons élevés
static ElevatedButtonThemeData _elevatedButtonTheme = ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: SpacingTokens.elevationSm,
shadowColor: ColorTokens.shadow,
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
textStyle: TypographyTokens.buttonMedium,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.buttonPaddingHorizontal,
vertical: SpacingTokens.buttonPaddingVertical,
),
minimumSize: const Size(
SpacingTokens.minButtonWidth,
SpacingTokens.buttonHeightMedium,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
),
);
/// Configuration des boutons remplis
static FilledButtonThemeData _filledButtonTheme = FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
textStyle: TypographyTokens.buttonMedium,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.buttonPaddingHorizontal,
vertical: SpacingTokens.buttonPaddingVertical,
),
minimumSize: const Size(
SpacingTokens.minButtonWidth,
SpacingTokens.buttonHeightMedium,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
),
);
/// Configuration des boutons avec contour
static OutlinedButtonThemeData _outlinedButtonTheme = OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: ColorTokens.primary,
textStyle: TypographyTokens.buttonMedium,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.buttonPaddingHorizontal,
vertical: SpacingTokens.buttonPaddingVertical,
),
minimumSize: const Size(
SpacingTokens.minButtonWidth,
SpacingTokens.buttonHeightMedium,
),
side: const BorderSide(
color: ColorTokens.outline,
width: 1.0,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
),
);
/// Configuration des boutons texte
static TextButtonThemeData _textButtonTheme = TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: ColorTokens.primary,
textStyle: TypographyTokens.buttonMedium,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.buttonPaddingHorizontal,
vertical: SpacingTokens.buttonPaddingVertical,
),
minimumSize: const Size(
SpacingTokens.minButtonWidth,
SpacingTokens.buttonHeightMedium,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
),
);
/// Configuration des champs de saisie
static InputDecorationTheme _inputDecorationTheme = InputDecorationTheme(
filled: true,
fillColor: ColorTokens.surfaceContainer,
labelStyle: TypographyTokens.inputLabel,
hintStyle: TypographyTokens.inputHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
borderSide: const BorderSide(color: ColorTokens.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
borderSide: const BorderSide(color: ColorTokens.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
borderSide: const BorderSide(color: ColorTokens.primary, width: 2.0),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
borderSide: const BorderSide(color: ColorTokens.error),
),
contentPadding: const EdgeInsets.all(SpacingTokens.formPadding),
);
/// Configuration de la barre de navigation
static NavigationBarThemeData _navigationBarTheme = NavigationBarThemeData(
backgroundColor: ColorTokens.navigationBackground,
indicatorColor: ColorTokens.navigationIndicator,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return TypographyTokens.navigationLabelSelected;
}
return TypographyTokens.navigationLabel;
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(color: ColorTokens.navigationSelected);
}
return const IconThemeData(color: ColorTokens.navigationUnselected);
}),
);
/// Configuration du drawer de navigation
static NavigationDrawerThemeData _navigationDrawerTheme = NavigationDrawerThemeData(
backgroundColor: ColorTokens.surfaceContainer,
elevation: SpacingTokens.elevationMd,
shadowColor: ColorTokens.shadow,
surfaceTintColor: ColorTokens.surfaceContainer,
indicatorColor: ColorTokens.primaryContainer,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return TypographyTokens.navigationLabelSelected;
}
return TypographyTokens.navigationLabel;
}),
);
/// Configuration des dialogues
static DialogTheme _dialogTheme = DialogTheme(
backgroundColor: ColorTokens.surfaceContainer,
elevation: SpacingTokens.elevationLg,
shadowColor: ColorTokens.shadow,
surfaceTintColor: ColorTokens.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
),
titleTextStyle: TypographyTokens.headlineSmall,
contentTextStyle: TypographyTokens.bodyMedium,
);
/// Configuration des snackbars
static SnackBarThemeData _snackBarTheme = SnackBarThemeData(
backgroundColor: ColorTokens.onSurface,
contentTextStyle: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.surface,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
behavior: SnackBarBehavior.floating,
);
/// Configuration des puces
static ChipThemeData _chipTheme = ChipThemeData(
backgroundColor: ColorTokens.surfaceVariant,
selectedColor: ColorTokens.primaryContainer,
labelStyle: TypographyTokens.labelMedium,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.md,
vertical: SpacingTokens.sm,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
);
/// Configuration des éléments de liste
static ListTileThemeData _listTileTheme = ListTileThemeData(
contentPadding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.xl,
vertical: SpacingTokens.md,
),
titleTextStyle: TypographyTokens.titleMedium,
subtitleTextStyle: TypographyTokens.bodyMedium,
leadingAndTrailingTextStyle: TypographyTokens.labelMedium,
minVerticalPadding: SpacingTokens.md,
);
/// Configuration des onglets
static TabBarTheme _tabBarTheme = TabBarTheme(
labelColor: ColorTokens.primary,
unselectedLabelColor: ColorTokens.onSurfaceVariant,
labelStyle: TypographyTokens.titleSmall,
unselectedLabelStyle: TypographyTokens.titleSmall,
indicator: UnderlineTabIndicator(
borderSide: const BorderSide(
color: ColorTokens.primary,
width: 2.0,
),
borderRadius: BorderRadius.circular(SpacingTokens.radiusXs),
),
);
/// Configuration des dividers
static DividerThemeData _dividerTheme = DividerThemeData(
color: ColorTokens.outline,
thickness: 1.0,
space: SpacingTokens.md,
);
/// Configuration des icônes
static IconThemeData _iconTheme = IconThemeData(
color: ColorTokens.onSurfaceVariant,
size: 24.0,
);
/// Configuration des transitions de page
static PageTransitionsTheme _pageTransitionsTheme = PageTransitionsTheme(
builders: {
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
);
/// Extensions personnalisées - Couleurs
static CustomColors _customColors = CustomColors();
/// Extensions personnalisées - Espacements
static CustomSpacing _customSpacing = CustomSpacing();
}
/// Extension de couleurs personnalisées
class CustomColors extends ThemeExtension<CustomColors> {
const CustomColors();
@override
CustomColors copyWith() => const CustomColors();
@override
CustomColors lerp(ThemeExtension<CustomColors>? other, double t) {
return const CustomColors();
}
}
/// Extension d'espacements personnalisés
class CustomSpacing extends ThemeExtension<CustomSpacing> {
const CustomSpacing();
@override
CustomSpacing copyWith() => const CustomSpacing();
@override
CustomSpacing lerp(ThemeExtension<CustomSpacing>? other, double t) {
return const CustomSpacing();
}
}

View File

@@ -0,0 +1,158 @@
/// Design Tokens - Couleurs
///
/// Palette de couleurs sophistiquée inspirée des tendances UI/UX 2024-2025
/// Basée sur les principes de Material Design 3 et les meilleures pratiques
/// d'applications professionnelles d'entreprise.
library color_tokens;
import 'package:flutter/material.dart';
/// Tokens de couleurs primaires - Palette sophistiquée
class ColorTokens {
ColorTokens._();
// ═══════════════════════════════════════════════════════════════════════════
// COULEURS PRIMAIRES - Bleu professionnel moderne
// ═══════════════════════════════════════════════════════════════════════════
/// Couleur primaire principale - Bleu corporate moderne
static const Color primary = Color(0xFF1E3A8A); // Bleu profond
static const Color primaryLight = Color(0xFF3B82F6); // Bleu vif
static const Color primaryDark = Color(0xFF1E40AF); // Bleu sombre
static const Color primaryContainer = Color(0xFFDEEAFF); // Container bleu clair
static const Color onPrimary = Color(0xFFFFFFFF); // Texte sur primaire
static const Color onPrimaryContainer = Color(0xFF001D36); // Texte sur container
// ═══════════════════════════════════════════════════════════════════════════
// COULEURS SECONDAIRES - Accent sophistiqué
// ═══════════════════════════════════════════════════════════════════════════
static const Color secondary = Color(0xFF6366F1); // Indigo moderne
static const Color secondaryLight = Color(0xFF8B5CF6); // Violet clair
static const Color secondaryDark = Color(0xFF4F46E5); // Indigo sombre
static const Color secondaryContainer = Color(0xFFE0E7FF); // Container indigo
static const Color onSecondary = Color(0xFFFFFFFF);
static const Color onSecondaryContainer = Color(0xFF1E1B3A);
// ═══════════════════════════════════════════════════════════════════════════
// COULEURS TERTIAIRES - Accent complémentaire
// ═══════════════════════════════════════════════════════════════════════════
static const Color tertiary = Color(0xFF059669); // Vert émeraude
static const Color tertiaryLight = Color(0xFF10B981); // Vert clair
static const Color tertiaryDark = Color(0xFF047857); // Vert sombre
static const Color tertiaryContainer = Color(0xFFD1FAE5); // Container vert
static const Color onTertiary = Color(0xFFFFFFFF);
static const Color onTertiaryContainer = Color(0xFF002114);
// ═══════════════════════════════════════════════════════════════════════════
// COULEURS NEUTRES - Échelle de gris sophistiquée
// ═══════════════════════════════════════════════════════════════════════════
static const Color surface = Color(0xFFFAFAFA); // Surface principale
static const Color surfaceVariant = Color(0xFFF5F5F5); // Surface variante
static const Color surfaceContainer = Color(0xFFFFFFFF); // Container surface
static const Color surfaceContainerHigh = Color(0xFFF8F9FA); // Container élevé
static const Color surfaceContainerHighest = Color(0xFFE5E7EB); // Container max
static const Color onSurface = Color(0xFF1F2937); // Texte principal
static const Color onSurfaceVariant = Color(0xFF6B7280); // Texte secondaire
static const Color textSecondary = Color(0xFF6B7280); // Texte secondaire (alias)
static const Color outline = Color(0xFFD1D5DB); // Bordures
static const Color outlineVariant = Color(0xFFE5E7EB); // Bordures claires
// ═══════════════════════════════════════════════════════════════════════════
// COULEURS SÉMANTIQUES - États et feedback
// ═══════════════════════════════════════════════════════════════════════════
/// Couleurs de succès
static const Color success = Color(0xFF10B981); // Vert succès
static const Color successLight = Color(0xFF34D399); // Vert clair
static const Color successDark = Color(0xFF059669); // Vert sombre
static const Color successContainer = Color(0xFFECFDF5); // Container succès
static const Color onSuccess = Color(0xFFFFFFFF);
static const Color onSuccessContainer = Color(0xFF002114);
/// Couleurs d'erreur
static const Color error = Color(0xFFDC2626); // Rouge erreur
static const Color errorLight = Color(0xFFEF4444); // Rouge clair
static const Color errorDark = Color(0xFFB91C1C); // Rouge sombre
static const Color errorContainer = Color(0xFFFEF2F2); // Container erreur
static const Color onError = Color(0xFFFFFFFF);
static const Color onErrorContainer = Color(0xFF410002);
/// Couleurs d'avertissement
static const Color warning = Color(0xFFF59E0B); // Orange avertissement
static const Color warningLight = Color(0xFFFBBF24); // Orange clair
static const Color warningDark = Color(0xFFD97706); // Orange sombre
static const Color warningContainer = Color(0xFFFEF3C7); // Container avertissement
static const Color onWarning = Color(0xFFFFFFFF);
static const Color onWarningContainer = Color(0xFF2D1B00);
/// Couleurs d'information
static const Color info = Color(0xFF0EA5E9); // Bleu info
static const Color infoLight = Color(0xFF38BDF8); // Bleu clair
static const Color infoDark = Color(0xFF0284C7); // Bleu sombre
static const Color infoContainer = Color(0xFFE0F2FE); // Container info
static const Color onInfo = Color(0xFFFFFFFF);
static const Color onInfoContainer = Color(0xFF001D36);
// ═══════════════════════════════════════════════════════════════════════════
// COULEURS SPÉCIALISÉES - Interface avancée
// ═══════════════════════════════════════════════════════════════════════════
/// Couleurs de navigation
static const Color navigationBackground = Color(0xFFFFFFFF);
static const Color navigationSelected = Color(0xFF1E3A8A);
static const Color navigationUnselected = Color(0xFF6B7280);
static const Color navigationIndicator = Color(0xFF3B82F6);
/// Couleurs d'élévation et ombres
static const Color shadow = Color(0x1A000000); // Ombre légère
static const Color shadowMedium = Color(0x33000000); // Ombre moyenne
static const Color shadowHigh = Color(0x4D000000); // Ombre forte
/// Couleurs de glassmorphism (tendance 2024-2025)
static const Color glassBackground = Color(0x80FFFFFF); // Fond verre
static const Color glassBorder = Color(0x33FFFFFF); // Bordure verre
static const Color glassOverlay = Color(0x0DFFFFFF); // Overlay verre
/// Couleurs de gradient (tendance moderne)
static const List<Color> primaryGradient = [
Color(0xFF1E3A8A),
Color(0xFF3B82F6),
];
static const List<Color> secondaryGradient = [
Color(0xFF6366F1),
Color(0xFF8B5CF6),
];
static const List<Color> successGradient = [
Color(0xFF059669),
Color(0xFF10B981),
];
// ═══════════════════════════════════════════════════════════════════════════
// MÉTHODES UTILITAIRES
// ═══════════════════════════════════════════════════════════════════════════
/// Obtient une couleur avec opacité
static Color withOpacity(Color color, double opacity) {
return color.withOpacity(opacity);
}
/// Obtient une couleur plus claire
static Color lighten(Color color, [double amount = 0.1]) {
final hsl = HSLColor.fromColor(color);
final lightness = (hsl.lightness + amount).clamp(0.0, 1.0);
return hsl.withLightness(lightness).toColor();
}
/// Obtient une couleur plus sombre
static Color darken(Color color, [double amount = 0.1]) {
final hsl = HSLColor.fromColor(color);
final lightness = (hsl.lightness - amount).clamp(0.0, 1.0);
return hsl.withLightness(lightness).toColor();
}
}

View File

@@ -0,0 +1,23 @@
/// Tokens de rayon pour le design system
/// Définit les rayons de bordure standardisés de l'application
library radius_tokens;
/// Tokens de rayon
class RadiusTokens {
RadiusTokens._();
/// Small - 4px
static const double sm = 4.0;
/// Medium - 8px
static const double md = 8.0;
/// Large - 12px
static const double lg = 12.0;
/// Extra large - 16px
static const double xl = 16.0;
/// Round - 50px
static const double round = 50.0;
}

View File

@@ -0,0 +1,194 @@
/// Design Tokens - Espacements
///
/// Système d'espacement cohérent basé sur une grille de 4px
/// Optimisé pour la lisibilité et l'harmonie visuelle
library spacing_tokens;
/// Tokens d'espacement - Système de grille moderne
class SpacingTokens {
SpacingTokens._();
// ═══════════════════════════════════════════════════════════════════════════
// ESPACEMENT DE BASE - Grille 4px
// ═══════════════════════════════════════════════════════════════════════════
/// Unité de base (4px) - Fondation du système
static const double baseUnit = 4.0;
/// Espacement minimal (2px) - Détails fins
static const double xs = baseUnit * 0.5; // 2px
/// Espacement très petit (4px) - Éléments adjacents
static const double sm = baseUnit * 1; // 4px
/// Espacement petit (8px) - Espacement interne léger
static const double md = baseUnit * 2; // 8px
/// Espacement moyen (12px) - Espacement standard
static const double lg = baseUnit * 3; // 12px
/// Espacement large (16px) - Séparation de composants
static const double xl = baseUnit * 4; // 16px
/// Espacement très large (20px) - Séparation importante
static const double xxl = baseUnit * 5; // 20px
/// Espacement extra large (24px) - Sections principales
static const double xxxl = baseUnit * 6; // 24px
/// Espacement massif (32px) - Séparation majeure
static const double huge = baseUnit * 8; // 32px
/// Espacement géant (48px) - Espacement héroïque
static const double giant = baseUnit * 12; // 48px
// ═══════════════════════════════════════════════════════════════════════════
// ESPACEMENTS SPÉCIALISÉS - Composants spécifiques
// ═══════════════════════════════════════════════════════════════════════════
/// Padding des conteneurs
static const double containerPaddingSmall = lg; // 12px
static const double containerPaddingMedium = xl; // 16px
static const double containerPaddingLarge = xxxl; // 24px
/// Marges des cartes
static const double cardMargin = xl; // 16px
static const double cardPadding = xl; // 16px
static const double cardPaddingLarge = xxxl; // 24px
/// Espacement des listes
static const double listItemSpacing = md; // 8px
static const double listSectionSpacing = xxxl; // 24px
/// Espacement des boutons
static const double buttonPaddingHorizontal = xl; // 16px
static const double buttonPaddingVertical = lg; // 12px
static const double buttonSpacing = md; // 8px
/// Espacement des formulaires
static const double formFieldSpacing = xl; // 16px
static const double formSectionSpacing = xxxl; // 24px
static const double formPadding = xl; // 16px
/// Espacement de navigation
static const double navigationPadding = xl; // 16px
static const double navigationItemSpacing = md; // 8px
static const double navigationSectionSpacing = xxxl; // 24px
/// Espacement des en-têtes
static const double headerPadding = xl; // 16px
static const double headerHeight = 56.0; // Hauteur standard
static const double headerElevation = 4.0; // Élévation
/// Espacement des onglets
static const double tabPadding = xl; // 16px
static const double tabHeight = 48.0; // Hauteur standard
/// Espacement des dialogues
static const double dialogPadding = xxxl; // 24px
static const double dialogMargin = xl; // 16px
/// Espacement des snackbars
static const double snackbarMargin = xl; // 16px
static const double snackbarPadding = xl; // 16px
// ═══════════════════════════════════════════════════════════════════════════
// RAYONS DE BORDURE - Système cohérent
// ═══════════════════════════════════════════════════════════════════════════
/// Rayon minimal (2px) - Détails subtils
static const double radiusXs = 2.0;
/// Rayon petit (4px) - Éléments fins
static const double radiusSm = 4.0;
/// Rayon moyen (8px) - Standard
static const double radiusMd = 8.0;
/// Rayon large (12px) - Cartes et composants
static const double radiusLg = 12.0;
/// Rayon très large (16px) - Conteneurs principaux
static const double radiusXl = 16.0;
/// Rayon extra large (20px) - Éléments héroïques
static const double radiusXxl = 20.0;
/// Rayon circulaire (999px) - Boutons ronds
static const double radiusCircular = 999.0;
// ═══════════════════════════════════════════════════════════════════════════
// ÉLÉVATIONS - Système d'ombres
// ═══════════════════════════════════════════════════════════════════════════
/// Élévation minimale
static const double elevationXs = 1.0;
/// Élévation petite
static const double elevationSm = 2.0;
/// Élévation moyenne
static const double elevationMd = 4.0;
/// Élévation large
static const double elevationLg = 8.0;
/// Élévation très large
static const double elevationXl = 12.0;
/// Élévation maximale
static const double elevationMax = 24.0;
// ═══════════════════════════════════════════════════════════════════════════
// DIMENSIONS FIXES - Composants standardisés
// ═══════════════════════════════════════════════════════════════════════════
/// Hauteurs de boutons
static const double buttonHeightSmall = 32.0;
static const double buttonHeightMedium = 40.0;
static const double buttonHeightLarge = 48.0;
/// Hauteurs d'éléments de liste
static const double listItemHeightSmall = 48.0;
static const double listItemHeightMedium = 56.0;
static const double listItemHeightLarge = 72.0;
/// Largeurs minimales
static const double minTouchTarget = 44.0; // Cible tactile minimale
static const double minButtonWidth = 64.0; // Largeur minimale bouton
/// Largeurs maximales
static const double maxContentWidth = 600.0; // Largeur max contenu
static const double maxDialogWidth = 400.0; // Largeur max dialogue
// ═══════════════════════════════════════════════════════════════════════════
// MÉTHODES UTILITAIRES
// ═══════════════════════════════════════════════════════════════════════════
/// Calcule un espacement basé sur l'unité de base
static double spacing(double multiplier) {
return baseUnit * multiplier;
}
/// Obtient un espacement responsive basé sur la largeur d'écran
static double responsiveSpacing(double screenWidth) {
if (screenWidth < 600) {
return xl; // Mobile
} else if (screenWidth < 1200) {
return xxxl; // Tablette
} else {
return huge; // Desktop
}
}
/// Obtient un padding responsive
static double responsivePadding(double screenWidth) {
if (screenWidth < 600) {
return xl; // 16px mobile
} else if (screenWidth < 1200) {
return xxxl; // 24px tablette
} else {
return huge; // 32px desktop
}
}
}

View File

@@ -0,0 +1,15 @@
/// Export de tous les tokens de design
/// Facilite l'importation des tokens dans l'application
library tokens;
// Tokens de couleur
export 'color_tokens.dart';
// Tokens de typographie
export 'typography_tokens.dart';
// Tokens d'espacement
export 'spacing_tokens.dart';
// Tokens de rayon
export 'radius_tokens.dart';

View File

@@ -0,0 +1,296 @@
/// Design Tokens - Typographie
///
/// Système typographique sophistiqué basé sur les tendances 2024-2025
/// Hiérarchie claire et lisibilité optimale pour applications professionnelles
library typography_tokens;
import 'package:flutter/material.dart';
import 'color_tokens.dart';
/// Tokens typographiques - Système de texte moderne
class TypographyTokens {
TypographyTokens._();
// ═══════════════════════════════════════════════════════════════════════════
// FAMILLES DE POLICES
// ═══════════════════════════════════════════════════════════════════════════
/// Police principale - Inter (moderne et lisible)
static const String primaryFontFamily = 'Inter';
/// Police secondaire - SF Pro Display (élégante)
static const String secondaryFontFamily = 'SF Pro Display';
/// Police monospace - JetBrains Mono (code et données)
static const String monospaceFontFamily = 'JetBrains Mono';
// ═══════════════════════════════════════════════════════════════════════════
// ÉCHELLE TYPOGRAPHIQUE - Basée sur Material Design 3
// ═══════════════════════════════════════════════════════════════════════════
/// Display - Titres principaux et héros
static const TextStyle displayLarge = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 57.0,
fontWeight: FontWeight.w400,
letterSpacing: -0.25,
height: 1.12,
color: ColorTokens.onSurface,
);
static const TextStyle displayMedium = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 45.0,
fontWeight: FontWeight.w400,
letterSpacing: 0.0,
height: 1.16,
color: ColorTokens.onSurface,
);
static const TextStyle displaySmall = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 36.0,
fontWeight: FontWeight.w400,
letterSpacing: 0.0,
height: 1.22,
color: ColorTokens.onSurface,
);
/// Headline - Titres de sections
static const TextStyle headlineLarge = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 32.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.0,
height: 1.25,
color: ColorTokens.onSurface,
);
static const TextStyle headlineMedium = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 28.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.0,
height: 1.29,
color: ColorTokens.onSurface,
);
static const TextStyle headlineSmall = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 24.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.0,
height: 1.33,
color: ColorTokens.onSurface,
);
/// Title - Titres de composants
static const TextStyle titleLarge = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 22.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.0,
height: 1.27,
color: ColorTokens.onSurface,
);
static const TextStyle titleMedium = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 16.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.15,
height: 1.50,
color: ColorTokens.onSurface,
);
static const TextStyle titleSmall = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 14.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.43,
color: ColorTokens.onSurface,
);
/// Label - Étiquettes et boutons
static const TextStyle labelLarge = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 14.0,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
height: 1.43,
color: ColorTokens.onSurface,
);
static const TextStyle labelMedium = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 12.0,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
height: 1.33,
color: ColorTokens.onSurface,
);
static const TextStyle labelSmall = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 11.0,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
height: 1.45,
color: ColorTokens.onSurface,
);
/// Body - Texte de contenu
static const TextStyle bodyLarge = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 16.0,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
height: 1.50,
color: ColorTokens.onSurface,
);
static const TextStyle bodyMedium = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 14.0,
fontWeight: FontWeight.w400,
letterSpacing: 0.25,
height: 1.43,
color: ColorTokens.onSurface,
);
static const TextStyle bodySmall = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 12.0,
fontWeight: FontWeight.w400,
letterSpacing: 0.4,
height: 1.33,
color: ColorTokens.onSurface,
);
// ═══════════════════════════════════════════════════════════════════════════
// STYLES SPÉCIALISÉS - Interface UnionFlow
// ═══════════════════════════════════════════════════════════════════════════
/// Navigation - Styles pour menu et navigation
static const TextStyle navigationLabel = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 14.0,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
height: 1.43,
color: ColorTokens.navigationUnselected,
);
static const TextStyle navigationLabelSelected = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 14.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.43,
color: ColorTokens.navigationSelected,
);
/// Cartes et composants
static const TextStyle cardTitle = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 18.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.0,
height: 1.33,
color: ColorTokens.onSurface,
);
static const TextStyle cardSubtitle = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 14.0,
fontWeight: FontWeight.w400,
letterSpacing: 0.25,
height: 1.43,
color: ColorTokens.onSurfaceVariant,
);
static const TextStyle cardValue = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 24.0,
fontWeight: FontWeight.w700,
letterSpacing: 0.0,
height: 1.25,
color: ColorTokens.primary,
);
/// Boutons
static const TextStyle buttonLarge = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 16.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.25,
color: ColorTokens.onPrimary,
);
static const TextStyle buttonMedium = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 14.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.29,
color: ColorTokens.onPrimary,
);
static const TextStyle buttonSmall = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 12.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
height: 1.33,
color: ColorTokens.onPrimary,
);
/// Formulaires
static const TextStyle inputLabel = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 14.0,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
height: 1.43,
color: ColorTokens.onSurfaceVariant,
);
static const TextStyle inputText = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 16.0,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
height: 1.50,
color: ColorTokens.onSurface,
);
static const TextStyle inputHint = TextStyle(
fontFamily: primaryFontFamily,
fontSize: 16.0,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
height: 1.50,
color: ColorTokens.onSurfaceVariant,
);
// ═══════════════════════════════════════════════════════════════════════════
// MÉTHODES UTILITAIRES
// ═══════════════════════════════════════════════════════════════════════════
/// Applique une couleur à un style
static TextStyle withColor(TextStyle style, Color color) {
return style.copyWith(color: color);
}
/// Applique un poids de police
static TextStyle withWeight(TextStyle style, FontWeight weight) {
return style.copyWith(fontWeight: weight);
}
/// Applique une taille de police
static TextStyle withSize(TextStyle style, double size) {
return style.copyWith(fontSize: size);
}
}

View File

@@ -1,126 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// InjectableConfigGenerator
// **************************************************************************
// ignore_for_file: type=lint
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:flutter_local_notifications/flutter_local_notifications.dart'
as _i163;
import 'package:get_it/get_it.dart' as _i174;
import 'package:injectable/injectable.dart' as _i526;
import 'package:shared_preferences/shared_preferences.dart' as _i460;
import 'package:unionflow_mobile_apps/core/auth/bloc/auth_bloc.dart' as _i635;
import 'package:unionflow_mobile_apps/core/auth/services/auth_api_service.dart'
as _i705;
import 'package:unionflow_mobile_apps/core/auth/services/auth_service.dart'
as _i423;
import 'package:unionflow_mobile_apps/core/auth/services/keycloak_webview_auth_service.dart'
as _i68;
import 'package:unionflow_mobile_apps/core/auth/storage/secure_token_storage.dart'
as _i394;
import 'package:unionflow_mobile_apps/core/network/auth_interceptor.dart'
as _i772;
import 'package:unionflow_mobile_apps/core/network/dio_client.dart' as _i978;
import 'package:unionflow_mobile_apps/core/services/api_service.dart' as _i238;
import 'package:unionflow_mobile_apps/core/services/cache_service.dart'
as _i742;
import 'package:unionflow_mobile_apps/core/services/moov_money_service.dart'
as _i1053;
import 'package:unionflow_mobile_apps/core/services/notification_service.dart'
as _i421;
import 'package:unionflow_mobile_apps/core/services/orange_money_service.dart'
as _i135;
import 'package:unionflow_mobile_apps/core/services/payment_service.dart'
as _i132;
import 'package:unionflow_mobile_apps/core/services/wave_payment_service.dart'
as _i924;
import 'package:unionflow_mobile_apps/features/cotisations/data/repositories/cotisation_repository_impl.dart'
as _i991;
import 'package:unionflow_mobile_apps/features/cotisations/domain/repositories/cotisation_repository.dart'
as _i961;
import 'package:unionflow_mobile_apps/features/cotisations/presentation/bloc/cotisations_bloc.dart'
as _i919;
import 'package:unionflow_mobile_apps/features/evenements/data/repositories/evenement_repository_impl.dart'
as _i947;
import 'package:unionflow_mobile_apps/features/evenements/domain/repositories/evenement_repository.dart'
as _i351;
import 'package:unionflow_mobile_apps/features/evenements/presentation/bloc/evenement_bloc.dart'
as _i1001;
import 'package:unionflow_mobile_apps/features/members/data/repositories/membre_repository_impl.dart'
as _i108;
import 'package:unionflow_mobile_apps/features/members/domain/repositories/membre_repository.dart'
as _i930;
import 'package:unionflow_mobile_apps/features/members/presentation/bloc/membres_bloc.dart'
as _i41;
extension GetItInjectableX on _i174.GetIt {
// initializes the registration of main-scope dependencies inside of GetIt
_i174.GetIt init({
String? environment,
_i526.EnvironmentFilter? environmentFilter,
}) {
final gh = _i526.GetItHelper(
this,
environment,
environmentFilter,
);
gh.singleton<_i68.KeycloakWebViewAuthService>(
() => _i68.KeycloakWebViewAuthService());
gh.singleton<_i394.SecureTokenStorage>(() => _i394.SecureTokenStorage());
gh.singleton<_i772.AuthInterceptor>(() => _i772.AuthInterceptor());
gh.singleton<_i978.DioClient>(() => _i978.DioClient());
gh.singleton<_i705.AuthApiService>(
() => _i705.AuthApiService(gh<_i978.DioClient>()));
gh.singleton<_i238.ApiService>(
() => _i238.ApiService(gh<_i978.DioClient>()));
gh.lazySingleton<_i742.CacheService>(
() => _i742.CacheService(gh<_i460.SharedPreferences>()));
gh.singleton<_i423.AuthService>(() => _i423.AuthService(
gh<_i394.SecureTokenStorage>(),
gh<_i705.AuthApiService>(),
gh<_i772.AuthInterceptor>(),
gh<_i978.DioClient>(),
));
gh.lazySingleton<_i961.CotisationRepository>(
() => _i991.CotisationRepositoryImpl(
gh<_i238.ApiService>(),
gh<_i742.CacheService>(),
));
gh.lazySingleton<_i1053.MoovMoneyService>(
() => _i1053.MoovMoneyService(gh<_i238.ApiService>()));
gh.lazySingleton<_i135.OrangeMoneyService>(
() => _i135.OrangeMoneyService(gh<_i238.ApiService>()));
gh.lazySingleton<_i924.WavePaymentService>(
() => _i924.WavePaymentService(gh<_i238.ApiService>()));
gh.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>()));
gh.lazySingleton<_i421.NotificationService>(() => _i421.NotificationService(
gh<_i163.FlutterLocalNotificationsPlugin>(),
gh<_i460.SharedPreferences>(),
));
gh.lazySingleton<_i351.EvenementRepository>(
() => _i947.EvenementRepositoryImpl(gh<_i238.ApiService>()));
gh.lazySingleton<_i930.MembreRepository>(
() => _i108.MembreRepositoryImpl(gh<_i238.ApiService>()));
gh.factory<_i1001.EvenementBloc>(
() => _i1001.EvenementBloc(gh<_i351.EvenementRepository>()));
gh.lazySingleton<_i132.PaymentService>(() => _i132.PaymentService(
gh<_i238.ApiService>(),
gh<_i742.CacheService>(),
gh<_i924.WavePaymentService>(),
gh<_i135.OrangeMoneyService>(),
gh<_i1053.MoovMoneyService>(),
));
gh.factory<_i41.MembresBloc>(
() => _i41.MembresBloc(gh<_i930.MembreRepository>()));
gh.factory<_i919.CotisationsBloc>(() => _i919.CotisationsBloc(
gh<_i961.CotisationRepository>(),
gh<_i132.PaymentService>(),
gh<_i421.NotificationService>(),
));
return this;
}
}

View File

@@ -1,32 +0,0 @@
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'injection.config.dart';
/// Instance globale de GetIt pour l'injection de dépendances
final GetIt getIt = GetIt.instance;
/// Configure l'injection de dépendances
@InjectableInit()
Future<void> configureDependencies() async {
// Enregistrer SharedPreferences
final sharedPreferences = await SharedPreferences.getInstance();
getIt.registerSingleton<SharedPreferences>(sharedPreferences);
// Enregistrer FlutterLocalNotificationsPlugin
getIt.registerSingleton<FlutterLocalNotificationsPlugin>(
FlutterLocalNotificationsPlugin(),
);
// Initialiser les autres dépendances
getIt.init();
}
/// Réinitialise les dépendances (utile pour les tests)
Future<void> resetDependencies() async {
await getIt.reset();
await configureDependencies();
}

View File

@@ -1,486 +0,0 @@
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import '../failures/failures.dart';
import '../../shared/theme/app_theme.dart';
/// Service centralisé de gestion des erreurs
class ErrorHandler {
static const String _tag = 'ErrorHandler';
/// Gère les erreurs et affiche les messages appropriés à l'utilisateur
static void handleError(
BuildContext context,
dynamic error, {
String? customMessage,
VoidCallback? onRetry,
bool showSnackBar = true,
Duration duration = const Duration(seconds: 4),
}) {
final errorInfo = _analyzeError(error);
if (showSnackBar) {
_showErrorSnackBar(
context,
customMessage ?? errorInfo.userMessage,
errorInfo.type,
onRetry: onRetry,
duration: duration,
);
}
// Log l'erreur pour le debugging
_logError(errorInfo);
}
/// Affiche une boîte de dialogue d'erreur pour les erreurs critiques
static Future<void> showErrorDialog(
BuildContext context,
dynamic error, {
String? title,
String? customMessage,
VoidCallback? onRetry,
VoidCallback? onCancel,
}) async {
final errorInfo = _analyzeError(error);
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
_getErrorIcon(errorInfo.type),
color: _getErrorColor(errorInfo.type),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
title ?? _getErrorTitle(errorInfo.type),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customMessage ?? errorInfo.userMessage,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
if (errorInfo.suggestions.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Suggestions :',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
...errorInfo.suggestions.map((suggestion) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('', style: TextStyle(color: AppTheme.textSecondary)),
Expanded(
child: Text(
suggestion,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
),
],
),
)),
],
],
),
actions: [
if (onCancel != null)
TextButton(
onPressed: () {
Navigator.of(context).pop();
onCancel();
},
child: const Text('Annuler'),
),
if (onRetry != null)
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onRetry();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
child: const Text('Réessayer'),
)
else
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
child: const Text('OK'),
),
],
);
},
);
}
/// Analyse l'erreur et retourne les informations structurées
static ErrorInfo _analyzeError(dynamic error) {
if (error is DioException) {
return _analyzeDioError(error);
} else if (error is Failure) {
return _analyzeFailure(error);
} else if (error is Exception) {
return _analyzeException(error);
} else {
return ErrorInfo(
type: ErrorType.unknown,
userMessage: 'Une erreur inattendue s\'est produite',
technicalMessage: error.toString(),
suggestions: ['Veuillez réessayer plus tard'],
);
}
}
/// Analyse les erreurs Dio (réseau)
static ErrorInfo _analyzeDioError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Délai d\'attente dépassé',
technicalMessage: error.message ?? '',
suggestions: [
'Vérifiez votre connexion internet',
'Réessayez dans quelques instants',
],
);
case DioExceptionType.connectionError:
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Problème de connexion',
technicalMessage: error.message ?? '',
suggestions: [
'Vérifiez votre connexion internet',
'Vérifiez que le serveur est accessible',
],
);
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
switch (statusCode) {
case 400:
return ErrorInfo(
type: ErrorType.validation,
userMessage: 'Données invalides',
technicalMessage: error.response?.data?.toString() ?? '',
suggestions: ['Vérifiez les informations saisies'],
);
case 401:
return ErrorInfo(
type: ErrorType.authentication,
userMessage: 'Session expirée',
technicalMessage: 'Unauthorized',
suggestions: ['Reconnectez-vous à l\'application'],
);
case 403:
return ErrorInfo(
type: ErrorType.authorization,
userMessage: 'Accès non autorisé',
technicalMessage: 'Forbidden',
suggestions: ['Contactez votre administrateur'],
);
case 404:
return ErrorInfo(
type: ErrorType.notFound,
userMessage: 'Ressource non trouvée',
technicalMessage: 'Not Found',
suggestions: ['La ressource demandée n\'existe plus'],
);
case 500:
return ErrorInfo(
type: ErrorType.server,
userMessage: 'Erreur serveur',
technicalMessage: 'Internal Server Error',
suggestions: [
'Réessayez dans quelques instants',
'Contactez le support si le problème persiste',
],
);
default:
return ErrorInfo(
type: ErrorType.server,
userMessage: 'Erreur serveur (Code: $statusCode)',
technicalMessage: error.response?.data?.toString() ?? '',
suggestions: ['Réessayez plus tard'],
);
}
case DioExceptionType.cancel:
return ErrorInfo(
type: ErrorType.cancelled,
userMessage: 'Opération annulée',
technicalMessage: 'Request cancelled',
suggestions: [],
);
default:
return ErrorInfo(
type: ErrorType.unknown,
userMessage: 'Erreur de communication',
technicalMessage: error.message ?? '',
suggestions: ['Réessayez plus tard'],
);
}
}
/// Analyse les erreurs de type Failure
static ErrorInfo _analyzeFailure(Failure failure) {
switch (failure.runtimeType) {
case NetworkFailure:
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Problème de réseau',
technicalMessage: failure.message,
suggestions: [
'Vérifiez votre connexion internet',
'Réessayez dans quelques instants',
],
);
case ServerFailure:
return ErrorInfo(
type: ErrorType.server,
userMessage: 'Erreur serveur',
technicalMessage: failure.message,
suggestions: [
'Réessayez dans quelques instants',
'Contactez le support si le problème persiste',
],
);
case ValidationFailure:
return ErrorInfo(
type: ErrorType.validation,
userMessage: 'Données invalides',
technicalMessage: failure.message,
suggestions: ['Vérifiez les informations saisies'],
);
case AuthFailure:
return ErrorInfo(
type: ErrorType.authentication,
userMessage: 'Problème d\'authentification',
technicalMessage: failure.message,
suggestions: ['Reconnectez-vous à l\'application'],
);
default:
return ErrorInfo(
type: ErrorType.unknown,
userMessage: failure.message,
technicalMessage: failure.message,
suggestions: ['Réessayez plus tard'],
);
}
}
/// Analyse les exceptions génériques
static ErrorInfo _analyzeException(Exception exception) {
final message = exception.toString();
if (message.contains('connexion') || message.contains('network')) {
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Problème de connexion',
technicalMessage: message,
suggestions: ['Vérifiez votre connexion internet'],
);
} else if (message.contains('timeout')) {
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Délai d\'attente dépassé',
technicalMessage: message,
suggestions: ['Réessayez dans quelques instants'],
);
} else {
return ErrorInfo(
type: ErrorType.unknown,
userMessage: 'Une erreur s\'est produite',
technicalMessage: message,
suggestions: ['Réessayez plus tard'],
);
}
}
/// Affiche une SnackBar d'erreur avec style approprié
static void _showErrorSnackBar(
BuildContext context,
String message,
ErrorType type, {
VoidCallback? onRetry,
Duration duration = const Duration(seconds: 4),
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
_getErrorIcon(type),
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
],
),
backgroundColor: _getErrorColor(type),
duration: duration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: onRetry != null
? SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: onRetry,
)
: null,
),
);
}
/// Retourne l'icône appropriée pour le type d'erreur
static IconData _getErrorIcon(ErrorType type) {
switch (type) {
case ErrorType.network:
return Icons.wifi_off;
case ErrorType.server:
return Icons.error_outline;
case ErrorType.validation:
return Icons.warning_amber;
case ErrorType.authentication:
return Icons.lock_outline;
case ErrorType.authorization:
return Icons.block;
case ErrorType.notFound:
return Icons.search_off;
case ErrorType.cancelled:
return Icons.cancel_outlined;
case ErrorType.unknown:
default:
return Icons.error_outline;
}
}
/// Retourne la couleur appropriée pour le type d'erreur
static Color _getErrorColor(ErrorType type) {
switch (type) {
case ErrorType.network:
return AppTheme.warningColor;
case ErrorType.server:
return AppTheme.errorColor;
case ErrorType.validation:
return AppTheme.warningColor;
case ErrorType.authentication:
return AppTheme.errorColor;
case ErrorType.authorization:
return AppTheme.errorColor;
case ErrorType.notFound:
return AppTheme.infoColor;
case ErrorType.cancelled:
return AppTheme.textSecondary;
case ErrorType.unknown:
default:
return AppTheme.errorColor;
}
}
/// Retourne le titre approprié pour le type d'erreur
static String _getErrorTitle(ErrorType type) {
switch (type) {
case ErrorType.network:
return 'Problème de connexion';
case ErrorType.server:
return 'Erreur serveur';
case ErrorType.validation:
return 'Données invalides';
case ErrorType.authentication:
return 'Authentification requise';
case ErrorType.authorization:
return 'Accès non autorisé';
case ErrorType.notFound:
return 'Ressource introuvable';
case ErrorType.cancelled:
return 'Opération annulée';
case ErrorType.unknown:
default:
return 'Erreur';
}
}
/// Log l'erreur pour le debugging
static void _logError(ErrorInfo errorInfo) {
debugPrint('[$_tag] ${errorInfo.type.name}: ${errorInfo.technicalMessage}');
}
}
/// Types d'erreurs supportés
enum ErrorType {
network,
server,
validation,
authentication,
authorization,
notFound,
cancelled,
unknown,
}
/// Informations structurées sur une erreur
class ErrorInfo {
final ErrorType type;
final String userMessage;
final String technicalMessage;
final List<String> suggestions;
const ErrorInfo({
required this.type,
required this.userMessage,
required this.technicalMessage,
required this.suggestions,
});
}

View File

@@ -1,122 +0,0 @@
import 'package:equatable/equatable.dart';
abstract class Failure extends Equatable {
const Failure({required this.message, this.code});
final String message;
final String? code;
@override
List<Object?> get props => [message, code];
}
class ServerFailure extends Failure {
const ServerFailure({
required super.message,
super.code,
this.statusCode,
});
final int? statusCode;
@override
List<Object?> get props => [message, code, statusCode];
}
class NetworkFailure extends Failure {
const NetworkFailure({
required super.message,
super.code = 'NETWORK_ERROR',
});
}
class AuthFailure extends Failure {
const AuthFailure({
required super.message,
super.code = 'AUTH_ERROR',
});
}
class ValidationFailure extends Failure {
const ValidationFailure({
required super.message,
super.code = 'VALIDATION_ERROR',
this.field,
});
final String? field;
@override
List<Object?> get props => [message, code, field];
}
class CacheFailure extends Failure {
const CacheFailure({
required super.message,
super.code = 'CACHE_ERROR',
});
}
class UnknownFailure extends Failure {
const UnknownFailure({
required super.message,
super.code = 'UNKNOWN_ERROR',
});
}
// Extension pour convertir les exceptions en failures
extension ExceptionToFailure on Exception {
Failure toFailure() {
if (this is NetworkException) {
final ex = this as NetworkException;
return NetworkFailure(message: ex.message);
} else if (this is ServerException) {
final ex = this as ServerException;
return ServerFailure(
message: ex.message,
statusCode: ex.statusCode,
);
} else if (this is AuthException) {
final ex = this as AuthException;
return AuthFailure(message: ex.message);
} else if (this is ValidationException) {
final ex = this as ValidationException;
return ValidationFailure(
message: ex.message,
field: ex.field,
);
} else if (this is CacheException) {
final ex = this as CacheException;
return CacheFailure(message: ex.message);
}
return UnknownFailure(message: toString());
}
}
// Exceptions personnalisées
class NetworkException implements Exception {
const NetworkException(this.message);
final String message;
}
class ServerException implements Exception {
const ServerException(this.message, {this.statusCode});
final String message;
final int? statusCode;
}
class AuthException implements Exception {
const AuthException(this.message);
final String message;
}
class ValidationException implements Exception {
const ValidationException(this.message, {this.field});
final String message;
final String? field;
}
class CacheException implements Exception {
const CacheException(this.message);
final String message;
}

View File

@@ -1,271 +0,0 @@
/// Classes d'échec pour la gestion d'erreurs structurée
abstract class Failure {
final String message;
final String? code;
final Map<String, dynamic>? details;
const Failure({
required this.message,
this.code,
this.details,
});
@override
String toString() => 'Failure(message: $message, code: $code)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Failure &&
other.message == message &&
other.code == code;
}
@override
int get hashCode => message.hashCode ^ code.hashCode;
}
/// Échec réseau (problèmes de connectivité, timeout, etc.)
class NetworkFailure extends Failure {
const NetworkFailure({
required super.message,
super.code,
super.details,
});
factory NetworkFailure.noConnection() {
return const NetworkFailure(
message: 'Aucune connexion internet disponible',
code: 'NO_CONNECTION',
);
}
factory NetworkFailure.timeout() {
return const NetworkFailure(
message: 'Délai d\'attente dépassé',
code: 'TIMEOUT',
);
}
factory NetworkFailure.serverUnreachable() {
return const NetworkFailure(
message: 'Serveur inaccessible',
code: 'SERVER_UNREACHABLE',
);
}
}
/// Échec serveur (erreurs HTTP 5xx, erreurs API, etc.)
class ServerFailure extends Failure {
final int? statusCode;
const ServerFailure({
required super.message,
super.code,
super.details,
this.statusCode,
});
factory ServerFailure.internalError() {
return const ServerFailure(
message: 'Erreur interne du serveur',
code: 'INTERNAL_ERROR',
statusCode: 500,
);
}
factory ServerFailure.serviceUnavailable() {
return const ServerFailure(
message: 'Service temporairement indisponible',
code: 'SERVICE_UNAVAILABLE',
statusCode: 503,
);
}
factory ServerFailure.badGateway() {
return const ServerFailure(
message: 'Passerelle défaillante',
code: 'BAD_GATEWAY',
statusCode: 502,
);
}
}
/// Échec de validation (données invalides, contraintes non respectées)
class ValidationFailure extends Failure {
final Map<String, List<String>>? fieldErrors;
const ValidationFailure({
required super.message,
super.code,
super.details,
this.fieldErrors,
});
factory ValidationFailure.invalidData(String field, String error) {
return ValidationFailure(
message: 'Données invalides',
code: 'INVALID_DATA',
fieldErrors: {field: [error]},
);
}
factory ValidationFailure.requiredField(String field) {
return ValidationFailure(
message: 'Champ requis manquant',
code: 'REQUIRED_FIELD',
fieldErrors: {field: ['Ce champ est requis']},
);
}
factory ValidationFailure.multipleErrors(Map<String, List<String>> errors) {
return ValidationFailure(
message: 'Plusieurs erreurs de validation',
code: 'MULTIPLE_ERRORS',
fieldErrors: errors,
);
}
}
/// Échec d'authentification (login, permissions, tokens expirés)
class AuthFailure extends Failure {
const AuthFailure({
required super.message,
super.code,
super.details,
});
factory AuthFailure.invalidCredentials() {
return const AuthFailure(
message: 'Identifiants invalides',
code: 'INVALID_CREDENTIALS',
);
}
factory AuthFailure.tokenExpired() {
return const AuthFailure(
message: 'Session expirée, veuillez vous reconnecter',
code: 'TOKEN_EXPIRED',
);
}
factory AuthFailure.insufficientPermissions() {
return const AuthFailure(
message: 'Permissions insuffisantes',
code: 'INSUFFICIENT_PERMISSIONS',
);
}
factory AuthFailure.accountLocked() {
return const AuthFailure(
message: 'Compte verrouillé',
code: 'ACCOUNT_LOCKED',
);
}
}
/// Échec de données (ressource non trouvée, conflit, etc.)
class DataFailure extends Failure {
const DataFailure({
required super.message,
super.code,
super.details,
});
factory DataFailure.notFound(String resource) {
return DataFailure(
message: '$resource non trouvé(e)',
code: 'NOT_FOUND',
details: {'resource': resource},
);
}
factory DataFailure.alreadyExists(String resource) {
return DataFailure(
message: '$resource existe déjà',
code: 'ALREADY_EXISTS',
details: {'resource': resource},
);
}
factory DataFailure.conflict(String reason) {
return DataFailure(
message: 'Conflit de données : $reason',
code: 'CONFLICT',
details: {'reason': reason},
);
}
}
/// Échec de cache (données expirées, cache corrompu)
class CacheFailure extends Failure {
const CacheFailure({
required super.message,
super.code,
super.details,
});
factory CacheFailure.expired() {
return const CacheFailure(
message: 'Données en cache expirées',
code: 'CACHE_EXPIRED',
);
}
factory CacheFailure.corrupted() {
return const CacheFailure(
message: 'Cache corrompu',
code: 'CACHE_CORRUPTED',
);
}
}
/// Échec de fichier (lecture, écriture, format)
class FileFailure extends Failure {
const FileFailure({
required super.message,
super.code,
super.details,
});
factory FileFailure.notFound(String filePath) {
return FileFailure(
message: 'Fichier non trouvé',
code: 'FILE_NOT_FOUND',
details: {'filePath': filePath},
);
}
factory FileFailure.accessDenied(String filePath) {
return FileFailure(
message: 'Accès au fichier refusé',
code: 'ACCESS_DENIED',
details: {'filePath': filePath},
);
}
factory FileFailure.invalidFormat(String expectedFormat) {
return FileFailure(
message: 'Format de fichier invalide',
code: 'INVALID_FORMAT',
details: {'expectedFormat': expectedFormat},
);
}
}
/// Échec générique pour les cas non spécifiés
class UnknownFailure extends Failure {
const UnknownFailure({
required super.message,
super.code,
super.details,
});
factory UnknownFailure.fromException(Exception exception) {
return UnknownFailure(
message: 'Erreur inattendue : ${exception.toString()}',
code: 'UNKNOWN_ERROR',
details: {'exception': exception.toString()},
);
}
}

View File

@@ -1,459 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../shared/theme/app_theme.dart';
import '../animations/loading_animations.dart';
/// Service de feedback utilisateur avec différents types de notifications
class UserFeedback {
/// Affiche un message de succès
static void showSuccess(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
VoidCallback? onAction,
String? actionLabel,
}) {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: AppTheme.successColor,
duration: duration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: onAction != null && actionLabel != null
? SnackBarAction(
label: actionLabel,
textColor: Colors.white,
onPressed: onAction,
)
: null,
),
);
}
/// Affiche un message d'information
static void showInfo(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
VoidCallback? onAction,
String? actionLabel,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icons.info,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
],
),
backgroundColor: AppTheme.infoColor,
duration: duration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: onAction != null && actionLabel != null
? SnackBarAction(
label: actionLabel,
textColor: Colors.white,
onPressed: onAction,
)
: null,
),
);
}
/// Affiche un message d'avertissement
static void showWarning(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 4),
VoidCallback? onAction,
String? actionLabel,
}) {
HapticFeedback.mediumImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icons.warning,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: AppTheme.warningColor,
duration: duration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: onAction != null && actionLabel != null
? SnackBarAction(
label: actionLabel,
textColor: Colors.white,
onPressed: onAction,
)
: null,
),
);
}
/// Affiche une boîte de dialogue de confirmation
static Future<bool> showConfirmation(
BuildContext context, {
required String title,
required String message,
String confirmText = 'Confirmer',
String cancelText = 'Annuler',
Color? confirmColor,
IconData? icon,
bool isDangerous = false,
}) async {
HapticFeedback.mediumImpact();
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
if (icon != null) ...[
Icon(
icon,
color: isDangerous ? AppTheme.errorColor : AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: Text(
message,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(
cancelText,
style: const TextStyle(
color: AppTheme.textSecondary,
),
),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: confirmColor ??
(isDangerous ? AppTheme.errorColor : AppTheme.primaryColor),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(confirmText),
),
],
);
},
);
return result ?? false;
}
/// Affiche une boîte de dialogue de saisie
static Future<String?> showInputDialog(
BuildContext context, {
required String title,
required String label,
String? initialValue,
String? hintText,
String confirmText = 'OK',
String cancelText = 'Annuler',
TextInputType? keyboardType,
String? Function(String?)? validator,
int maxLines = 1,
}) async {
final controller = TextEditingController(text: initialValue);
final formKey = GlobalKey<FormState>();
final result = await showDialog<String>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Form(
key: formKey,
child: TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
keyboardType: keyboardType,
maxLines: maxLines,
validator: validator,
autofocus: true,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
cancelText,
style: const TextStyle(
color: AppTheme.textSecondary,
),
),
),
ElevatedButton(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
Navigator.of(context).pop(controller.text);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(confirmText),
),
],
);
},
);
controller.dispose();
return result;
}
/// Affiche un indicateur de chargement avec message et animation personnalisée
static void showLoading(
BuildContext context, {
String message = 'Chargement...',
bool barrierDismissible = false,
Widget? customLoader,
}) {
showDialog(
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return PopScope(
canPop: barrierDismissible,
child: AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
customLoader ?? LoadingAnimations.waves(
color: AppTheme.primaryColor,
size: 50,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
);
},
);
}
/// Affiche un indicateur de chargement avec animation de points
static void showLoadingDots(
BuildContext context, {
String message = 'Chargement...',
bool barrierDismissible = false,
}) {
showLoading(
context,
message: message,
barrierDismissible: barrierDismissible,
customLoader: LoadingAnimations.dots(
color: AppTheme.primaryColor,
size: 12,
),
);
}
/// Affiche un indicateur de chargement avec animation de spinner
static void showLoadingSpinner(
BuildContext context, {
String message = 'Chargement...',
bool barrierDismissible = false,
}) {
showLoading(
context,
message: message,
barrierDismissible: barrierDismissible,
customLoader: LoadingAnimations.spinner(
color: AppTheme.primaryColor,
size: 50,
),
);
}
/// Ferme l'indicateur de chargement
static void hideLoading(BuildContext context) {
Navigator.of(context).pop();
}
/// Affiche un toast personnalisé
static void showToast(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 2),
Color? backgroundColor,
Color? textColor,
IconData? icon,
}) {
final overlay = Overlay.of(context);
late OverlayEntry overlayEntry;
overlayEntry = OverlayEntry(
builder: (context) => Positioned(
bottom: 100,
left: 20,
right: 20,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor ?? AppTheme.textPrimary.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
color: textColor ?? Colors.white,
size: 20,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
message,
style: TextStyle(
color: textColor ?? Colors.white,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
],
),
),
),
),
);
overlay.insert(overlayEntry);
Future.delayed(duration, () {
overlayEntry.remove();
});
}
}

View File

@@ -1,326 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'cotisation_filter_model.g.dart';
/// Modèle pour les filtres de recherche des cotisations
/// Permet de filtrer les cotisations selon différents critères
@JsonSerializable()
class CotisationFilterModel {
final String? membreId;
final String? nomMembre;
final String? numeroMembre;
final List<String>? statuts;
final List<String>? typesCotisation;
final DateTime? dateEcheanceMin;
final DateTime? dateEcheanceMax;
final DateTime? datePaiementMin;
final DateTime? datePaiementMax;
final double? montantMin;
final double? montantMax;
final int? annee;
final int? mois;
final String? periode;
final bool? recurrente;
final bool? enRetard;
final bool? echeanceProche;
final String? methodePaiement;
final String? recherche;
final String? triPar;
final String? ordretri;
final int page;
final int size;
const CotisationFilterModel({
this.membreId,
this.nomMembre,
this.numeroMembre,
this.statuts,
this.typesCotisation,
this.dateEcheanceMin,
this.dateEcheanceMax,
this.datePaiementMin,
this.datePaiementMax,
this.montantMin,
this.montantMax,
this.annee,
this.mois,
this.periode,
this.recurrente,
this.enRetard,
this.echeanceProche,
this.methodePaiement,
this.recherche,
this.triPar,
this.ordreTriPar,
this.page = 0,
this.size = 20,
});
/// Factory pour créer depuis JSON
factory CotisationFilterModel.fromJson(Map<String, dynamic> json) =>
_$CotisationFilterModelFromJson(json);
/// Convertit vers JSON
Map<String, dynamic> toJson() => _$CotisationFilterModelToJson(this);
/// Crée un filtre vide
factory CotisationFilterModel.empty() {
return const CotisationFilterModel();
}
/// Crée un filtre pour les cotisations en retard
factory CotisationFilterModel.enRetard() {
return const CotisationFilterModel(
enRetard: true,
triPar: 'dateEcheance',
ordreTriPar: 'ASC',
);
}
/// Crée un filtre pour les cotisations avec échéance proche
factory CotisationFilterModel.echeanceProche() {
return const CotisationFilterModel(
echeanceProche: true,
triPar: 'dateEcheance',
ordreTriPar: 'ASC',
);
}
/// Crée un filtre pour un membre spécifique
factory CotisationFilterModel.parMembre(String membreId) {
return CotisationFilterModel(
membreId: membreId,
triPar: 'dateEcheance',
ordreTriPar: 'DESC',
);
}
/// Crée un filtre pour un statut spécifique
factory CotisationFilterModel.parStatut(String statut) {
return CotisationFilterModel(
statuts: [statut],
triPar: 'dateEcheance',
ordreTriPar: 'DESC',
);
}
/// Crée un filtre pour une période spécifique
factory CotisationFilterModel.parPeriode(int annee, [int? mois]) {
return CotisationFilterModel(
annee: annee,
mois: mois,
triPar: 'dateEcheance',
ordreTriPar: 'DESC',
);
}
/// Crée un filtre pour une recherche textuelle
factory CotisationFilterModel.recherche(String terme) {
return CotisationFilterModel(
recherche: terme,
triPar: 'dateCreation',
ordreTriPar: 'DESC',
);
}
/// Vérifie si le filtre est vide
bool get isEmpty {
return membreId == null &&
nomMembre == null &&
numeroMembre == null &&
(statuts == null || statuts!.isEmpty) &&
(typesCotisation == null || typesCotisation!.isEmpty) &&
dateEcheanceMin == null &&
dateEcheanceMax == null &&
datePaiementMin == null &&
datePaiementMax == null &&
montantMin == null &&
montantMax == null &&
annee == null &&
mois == null &&
periode == null &&
recurrente == null &&
enRetard == null &&
echeanceProche == null &&
methodePaiement == null &&
(recherche == null || recherche!.isEmpty);
}
/// Vérifie si le filtre a des critères actifs
bool get hasActiveFilters => !isEmpty;
/// Compte le nombre de filtres actifs
int get nombreFiltresActifs {
int count = 0;
if (membreId != null) count++;
if (nomMembre != null) count++;
if (numeroMembre != null) count++;
if (statuts != null && statuts!.isNotEmpty) count++;
if (typesCotisation != null && typesCotisation!.isNotEmpty) count++;
if (dateEcheanceMin != null || dateEcheanceMax != null) count++;
if (datePaiementMin != null || datePaiementMax != null) count++;
if (montantMin != null || montantMax != null) count++;
if (annee != null) count++;
if (mois != null) count++;
if (periode != null) count++;
if (recurrente != null) count++;
if (enRetard == true) count++;
if (echeanceProche == true) count++;
if (methodePaiement != null) count++;
if (recherche != null && recherche!.isNotEmpty) count++;
return count;
}
/// Retourne une description textuelle des filtres actifs
String get descriptionFiltres {
List<String> descriptions = [];
if (statuts != null && statuts!.isNotEmpty) {
descriptions.add('Statut: ${statuts!.join(', ')}');
}
if (typesCotisation != null && typesCotisation!.isNotEmpty) {
descriptions.add('Type: ${typesCotisation!.join(', ')}');
}
if (annee != null) {
String periodeDesc = 'Année: $annee';
if (mois != null) {
periodeDesc += ', Mois: $mois';
}
descriptions.add(periodeDesc);
}
if (enRetard == true) {
descriptions.add('En retard');
}
if (echeanceProche == true) {
descriptions.add('Échéance proche');
}
if (montantMin != null || montantMax != null) {
String montantDesc = 'Montant: ';
if (montantMin != null && montantMax != null) {
montantDesc += '${montantMin!.toStringAsFixed(0)} - ${montantMax!.toStringAsFixed(0)} XOF';
} else if (montantMin != null) {
montantDesc += '${montantMin!.toStringAsFixed(0)} XOF';
} else {
montantDesc += '${montantMax!.toStringAsFixed(0)} XOF';
}
descriptions.add(montantDesc);
}
if (recherche != null && recherche!.isNotEmpty) {
descriptions.add('Recherche: "$recherche"');
}
return descriptions.join('');
}
/// Convertit vers Map pour les paramètres de requête
Map<String, dynamic> toQueryParameters() {
Map<String, dynamic> params = {};
if (membreId != null) params['membreId'] = membreId;
if (statuts != null && statuts!.isNotEmpty) {
params['statut'] = statuts!.length == 1 ? statuts!.first : statuts!.join(',');
}
if (typesCotisation != null && typesCotisation!.isNotEmpty) {
params['typeCotisation'] = typesCotisation!.length == 1 ? typesCotisation!.first : typesCotisation!.join(',');
}
if (annee != null) params['annee'] = annee.toString();
if (mois != null) params['mois'] = mois.toString();
if (periode != null) params['periode'] = periode;
if (recurrente != null) params['recurrente'] = recurrente.toString();
if (enRetard == true) params['enRetard'] = 'true';
if (echeanceProche == true) params['echeanceProche'] = 'true';
if (methodePaiement != null) params['methodePaiement'] = methodePaiement;
if (recherche != null && recherche!.isNotEmpty) params['q'] = recherche;
if (triPar != null) params['sortBy'] = triPar;
if (ordreTriPar != null) params['sortOrder'] = ordreTriPar;
params['page'] = page.toString();
params['size'] = size.toString();
return params;
}
/// Copie avec modifications
CotisationFilterModel copyWith({
String? membreId,
String? nomMembre,
String? numeroMembre,
List<String>? statuts,
List<String>? typesCotisation,
DateTime? dateEcheanceMin,
DateTime? dateEcheanceMax,
DateTime? datePaiementMin,
DateTime? datePaiementMax,
double? montantMin,
double? montantMax,
int? annee,
int? mois,
String? periode,
bool? recurrente,
bool? enRetard,
bool? echeanceProche,
String? methodePaiement,
String? recherche,
String? triPar,
String? ordreTriPar,
int? page,
int? size,
}) {
return CotisationFilterModel(
membreId: membreId ?? this.membreId,
nomMembre: nomMembre ?? this.nomMembre,
numeroMembre: numeroMembre ?? this.numeroMembre,
statuts: statuts ?? this.statuts,
typesCotisation: typesCotisation ?? this.typesCotisation,
dateEcheanceMin: dateEcheanceMin ?? this.dateEcheanceMin,
dateEcheanceMax: dateEcheanceMax ?? this.dateEcheanceMax,
datePaiementMin: datePaiementMin ?? this.datePaiementMin,
datePaiementMax: datePaiementMax ?? this.datePaiementMax,
montantMin: montantMin ?? this.montantMin,
montantMax: montantMax ?? this.montantMax,
annee: annee ?? this.annee,
mois: mois ?? this.mois,
periode: periode ?? this.periode,
recurrente: recurrente ?? this.recurrente,
enRetard: enRetard ?? this.enRetard,
echeanceProche: echeanceProche ?? this.echeanceProche,
methodePaiement: methodePaiement ?? this.methodePaiement,
recherche: recherche ?? this.recherche,
triPar: triPar ?? this.triPar,
ordreTriPar: ordreTriPar ?? this.ordreTriPar,
page: page ?? this.page,
size: size ?? this.size,
);
}
/// Réinitialise tous les filtres
CotisationFilterModel clear() {
return const CotisationFilterModel();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CotisationFilterModel &&
other.membreId == membreId &&
other.statuts == statuts &&
other.typesCotisation == typesCotisation &&
other.annee == annee &&
other.mois == mois &&
other.recherche == recherche;
}
@override
int get hashCode => Object.hash(membreId, statuts, typesCotisation, annee, mois, recherche);
@override
String toString() {
return 'CotisationFilterModel(filtres actifs: $nombreFiltresActifs)';
}
}

View File

@@ -1,72 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cotisation_filter_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CotisationFilterModel _$CotisationFilterModelFromJson(
Map<String, dynamic> json) =>
CotisationFilterModel(
membreId: json['membreId'] as String?,
nomMembre: json['nomMembre'] as String?,
numeroMembre: json['numeroMembre'] as String?,
statuts:
(json['statuts'] as List<dynamic>?)?.map((e) => e as String).toList(),
typesCotisation: (json['typesCotisation'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
dateEcheanceMin: json['dateEcheanceMin'] == null
? null
: DateTime.parse(json['dateEcheanceMin'] as String),
dateEcheanceMax: json['dateEcheanceMax'] == null
? null
: DateTime.parse(json['dateEcheanceMax'] as String),
datePaiementMin: json['datePaiementMin'] == null
? null
: DateTime.parse(json['datePaiementMin'] as String),
datePaiementMax: json['datePaiementMax'] == null
? null
: DateTime.parse(json['datePaiementMax'] as String),
montantMin: (json['montantMin'] as num?)?.toDouble(),
montantMax: (json['montantMax'] as num?)?.toDouble(),
annee: (json['annee'] as num?)?.toInt(),
mois: (json['mois'] as num?)?.toInt(),
periode: json['periode'] as String?,
recurrente: json['recurrente'] as bool?,
enRetard: json['enRetard'] as bool?,
echeanceProche: json['echeanceProche'] as bool?,
methodePaiement: json['methodePaiement'] as String?,
recherche: json['recherche'] as String?,
triPar: json['triPar'] as String?,
page: (json['page'] as num?)?.toInt() ?? 0,
size: (json['size'] as num?)?.toInt() ?? 20,
);
Map<String, dynamic> _$CotisationFilterModelToJson(
CotisationFilterModel instance) =>
<String, dynamic>{
'membreId': instance.membreId,
'nomMembre': instance.nomMembre,
'numeroMembre': instance.numeroMembre,
'statuts': instance.statuts,
'typesCotisation': instance.typesCotisation,
'dateEcheanceMin': instance.dateEcheanceMin?.toIso8601String(),
'dateEcheanceMax': instance.dateEcheanceMax?.toIso8601String(),
'datePaiementMin': instance.datePaiementMin?.toIso8601String(),
'datePaiementMax': instance.datePaiementMax?.toIso8601String(),
'montantMin': instance.montantMin,
'montantMax': instance.montantMax,
'annee': instance.annee,
'mois': instance.mois,
'periode': instance.periode,
'recurrente': instance.recurrente,
'enRetard': instance.enRetard,
'echeanceProche': instance.echeanceProche,
'methodePaiement': instance.methodePaiement,
'recherche': instance.recherche,
'triPar': instance.triPar,
'page': instance.page,
'size': instance.size,
};

View File

@@ -1,277 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'cotisation_model.g.dart';
/// Modèle de données pour les cotisations
/// Correspond au CotisationDTO du backend
@JsonSerializable()
class CotisationModel {
final String id;
final String numeroReference;
final String membreId;
final String? nomMembre;
final String? numeroMembre;
final String typeCotisation;
final double montantDu;
final double montantPaye;
final String codeDevise;
final String statut;
final DateTime dateEcheance;
final DateTime? datePaiement;
final String? description;
final String? periode;
final int annee;
final int? mois;
final String? observations;
final bool recurrente;
final int nombreRappels;
final DateTime? dateDernierRappel;
final String? valideParId;
final String? nomValidateur;
final DateTime? dateValidation;
final String? methodePaiement;
final String? referencePaiement;
final DateTime dateCreation;
final DateTime? dateModification;
const CotisationModel({
required this.id,
required this.numeroReference,
required this.membreId,
this.nomMembre,
this.numeroMembre,
required this.typeCotisation,
required this.montantDu,
required this.montantPaye,
required this.codeDevise,
required this.statut,
required this.dateEcheance,
this.datePaiement,
this.description,
this.periode,
required this.annee,
this.mois,
this.observations,
required this.recurrente,
required this.nombreRappels,
this.dateDernierRappel,
this.valideParId,
this.nomValidateur,
this.dateValidation,
this.methodePaiement,
this.referencePaiement,
required this.dateCreation,
this.dateModification,
});
/// Factory pour créer depuis JSON
factory CotisationModel.fromJson(Map<String, dynamic> json) =>
_$CotisationModelFromJson(json);
/// Convertit vers JSON
Map<String, dynamic> toJson() => _$CotisationModelToJson(this);
/// Calcule le montant restant à payer
double get montantRestant => montantDu - montantPaye;
/// Vérifie si la cotisation est entièrement payée
bool get isEntierementPayee => montantRestant <= 0;
/// Vérifie si la cotisation est en retard
bool get isEnRetard {
return dateEcheance.isBefore(DateTime.now()) && !isEntierementPayee;
}
/// Retourne le pourcentage de paiement
double get pourcentagePaiement {
if (montantDu == 0) return 0;
return (montantPaye / montantDu * 100).clamp(0, 100);
}
/// Calcule le nombre de jours de retard
int get joursRetard {
if (!isEnRetard) return 0;
return DateTime.now().difference(dateEcheance).inDays;
}
/// Retourne la couleur associée au statut
String get couleurStatut {
switch (statut) {
case 'PAYEE':
return '#4CAF50'; // Vert
case 'EN_ATTENTE':
return '#FF9800'; // Orange
case 'EN_RETARD':
return '#F44336'; // Rouge
case 'PARTIELLEMENT_PAYEE':
return '#2196F3'; // Bleu
case 'ANNULEE':
return '#9E9E9E'; // Gris
default:
return '#757575'; // Gris foncé
}
}
/// Retourne le libellé du statut en français
String get libelleStatut {
switch (statut) {
case 'PAYEE':
return 'Payée';
case 'EN_ATTENTE':
return 'En attente';
case 'EN_RETARD':
return 'En retard';
case 'PARTIELLEMENT_PAYEE':
return 'Partiellement payée';
case 'ANNULEE':
return 'Annulée';
default:
return statut;
}
}
/// Retourne le libellé du type de cotisation
String get libelleTypeCotisation {
switch (typeCotisation) {
case 'MENSUELLE':
return 'Mensuelle';
case 'TRIMESTRIELLE':
return 'Trimestrielle';
case 'SEMESTRIELLE':
return 'Semestrielle';
case 'ANNUELLE':
return 'Annuelle';
case 'EXCEPTIONNELLE':
return 'Exceptionnelle';
case 'ADHESION':
return 'Adhésion';
default:
return typeCotisation;
}
}
/// Retourne l'icône associée au type de cotisation
String get iconeTypeCotisation {
switch (typeCotisation) {
case 'MENSUELLE':
return '📅';
case 'TRIMESTRIELLE':
return '📊';
case 'SEMESTRIELLE':
return '📈';
case 'ANNUELLE':
return '🗓️';
case 'EXCEPTIONNELLE':
return '';
case 'ADHESION':
return '🎯';
default:
return '💰';
}
}
/// Retourne le nombre de jours jusqu'à l'échéance
int get joursJusquEcheance {
final maintenant = DateTime.now();
final difference = dateEcheance.difference(maintenant);
return difference.inDays;
}
/// Vérifie si l'échéance approche (moins de 7 jours)
bool get echeanceProche {
return joursJusquEcheance <= 7 && joursJusquEcheance >= 0;
}
/// Retourne un message d'urgence basé sur l'échéance
String get messageUrgence {
final jours = joursJusquEcheance;
if (jours < 0) {
return 'En retard de ${-jours} jour${-jours > 1 ? 's' : ''}';
} else if (jours == 0) {
return 'Échéance aujourd\'hui';
} else if (jours <= 3) {
return 'Échéance dans $jours jour${jours > 1 ? 's' : ''}';
} else if (jours <= 7) {
return 'Échéance dans $jours jours';
} else {
return '';
}
}
/// Copie avec modifications
CotisationModel copyWith({
String? id,
String? numeroReference,
String? membreId,
String? nomMembre,
String? numeroMembre,
String? typeCotisation,
double? montantDu,
double? montantPaye,
String? codeDevise,
String? statut,
DateTime? dateEcheance,
DateTime? datePaiement,
String? description,
String? periode,
int? annee,
int? mois,
String? observations,
bool? recurrente,
int? nombreRappels,
DateTime? dateDernierRappel,
String? valideParId,
String? nomValidateur,
DateTime? dateValidation,
String? methodePaiement,
String? referencePaiement,
DateTime? dateCreation,
DateTime? dateModification,
}) {
return CotisationModel(
id: id ?? this.id,
numeroReference: numeroReference ?? this.numeroReference,
membreId: membreId ?? this.membreId,
nomMembre: nomMembre ?? this.nomMembre,
numeroMembre: numeroMembre ?? this.numeroMembre,
typeCotisation: typeCotisation ?? this.typeCotisation,
montantDu: montantDu ?? this.montantDu,
montantPaye: montantPaye ?? this.montantPaye,
codeDevise: codeDevise ?? this.codeDevise,
statut: statut ?? this.statut,
dateEcheance: dateEcheance ?? this.dateEcheance,
datePaiement: datePaiement ?? this.datePaiement,
description: description ?? this.description,
periode: periode ?? this.periode,
annee: annee ?? this.annee,
mois: mois ?? this.mois,
observations: observations ?? this.observations,
recurrente: recurrente ?? this.recurrente,
nombreRappels: nombreRappels ?? this.nombreRappels,
dateDernierRappel: dateDernierRappel ?? this.dateDernierRappel,
valideParId: valideParId ?? this.valideParId,
nomValidateur: nomValidateur ?? this.nomValidateur,
dateValidation: dateValidation ?? this.dateValidation,
methodePaiement: methodePaiement ?? this.methodePaiement,
referencePaiement: referencePaiement ?? this.referencePaiement,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CotisationModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
@override
String toString() {
return 'CotisationModel(id: $id, numeroReference: $numeroReference, '
'nomMembre: $nomMembre, typeCotisation: $typeCotisation, '
'montantDu: $montantDu, statut: $statut)';
}
}

View File

@@ -1,77 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cotisation_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CotisationModel _$CotisationModelFromJson(Map<String, dynamic> json) =>
CotisationModel(
id: json['id'] as String,
numeroReference: json['numeroReference'] as String,
membreId: json['membreId'] as String,
nomMembre: json['nomMembre'] as String?,
numeroMembre: json['numeroMembre'] as String?,
typeCotisation: json['typeCotisation'] as String,
montantDu: (json['montantDu'] as num).toDouble(),
montantPaye: (json['montantPaye'] as num).toDouble(),
codeDevise: json['codeDevise'] as String,
statut: json['statut'] as String,
dateEcheance: DateTime.parse(json['dateEcheance'] as String),
datePaiement: json['datePaiement'] == null
? null
: DateTime.parse(json['datePaiement'] as String),
description: json['description'] as String?,
periode: json['periode'] as String?,
annee: (json['annee'] as num).toInt(),
mois: (json['mois'] as num?)?.toInt(),
observations: json['observations'] as String?,
recurrente: json['recurrente'] as bool,
nombreRappels: (json['nombreRappels'] as num).toInt(),
dateDernierRappel: json['dateDernierRappel'] == null
? null
: DateTime.parse(json['dateDernierRappel'] as String),
valideParId: json['valideParId'] as String?,
nomValidateur: json['nomValidateur'] as String?,
dateValidation: json['dateValidation'] == null
? null
: DateTime.parse(json['dateValidation'] as String),
methodePaiement: json['methodePaiement'] as String?,
referencePaiement: json['referencePaiement'] as String?,
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
);
Map<String, dynamic> _$CotisationModelToJson(CotisationModel instance) =>
<String, dynamic>{
'id': instance.id,
'numeroReference': instance.numeroReference,
'membreId': instance.membreId,
'nomMembre': instance.nomMembre,
'numeroMembre': instance.numeroMembre,
'typeCotisation': instance.typeCotisation,
'montantDu': instance.montantDu,
'montantPaye': instance.montantPaye,
'codeDevise': instance.codeDevise,
'statut': instance.statut,
'dateEcheance': instance.dateEcheance.toIso8601String(),
'datePaiement': instance.datePaiement?.toIso8601String(),
'description': instance.description,
'periode': instance.periode,
'annee': instance.annee,
'mois': instance.mois,
'observations': instance.observations,
'recurrente': instance.recurrente,
'nombreRappels': instance.nombreRappels,
'dateDernierRappel': instance.dateDernierRappel?.toIso8601String(),
'valideParId': instance.valideParId,
'nomValidateur': instance.nomValidateur,
'dateValidation': instance.dateValidation?.toIso8601String(),
'methodePaiement': instance.methodePaiement,
'referencePaiement': instance.referencePaiement,
'dateCreation': instance.dateCreation.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
};

View File

@@ -1,295 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'cotisation_statistics_model.g.dart';
/// Modèle de données pour les statistiques des cotisations
/// Représente les métriques et analyses des cotisations
@JsonSerializable()
class CotisationStatisticsModel {
final int totalCotisations;
final double montantTotal;
final double montantPaye;
final double montantRestant;
final int cotisationsPayees;
final int cotisationsEnAttente;
final int cotisationsEnRetard;
final int cotisationsAnnulees;
final double tauxPaiement;
final double tauxRetard;
final double montantMoyenCotisation;
final double montantMoyenPaiement;
final Map<String, int>? repartitionParType;
final Map<String, double>? montantParType;
final Map<String, int>? repartitionParStatut;
final Map<String, double>? montantParStatut;
final Map<String, int>? evolutionMensuelle;
final Map<String, double>? chiffreAffaireMensuel;
final List<CotisationTrendModel>? tendances;
final DateTime dateCalcul;
final String? periode;
final int? annee;
final int? mois;
const CotisationStatisticsModel({
required this.totalCotisations,
required this.montantTotal,
required this.montantPaye,
required this.montantRestant,
required this.cotisationsPayees,
required this.cotisationsEnAttente,
required this.cotisationsEnRetard,
required this.cotisationsAnnulees,
required this.tauxPaiement,
required this.tauxRetard,
required this.montantMoyenCotisation,
required this.montantMoyenPaiement,
this.repartitionParType,
this.montantParType,
this.repartitionParStatut,
this.montantParStatut,
this.evolutionMensuelle,
this.chiffreAffaireMensuel,
this.tendances,
required this.dateCalcul,
this.periode,
this.annee,
this.mois,
});
/// Factory pour créer depuis JSON
factory CotisationStatisticsModel.fromJson(Map<String, dynamic> json) =>
_$CotisationStatisticsModelFromJson(json);
/// Convertit vers JSON
Map<String, dynamic> toJson() => _$CotisationStatisticsModelToJson(this);
/// Calcule le pourcentage de cotisations payées
double get pourcentageCotisationsPayees {
if (totalCotisations == 0) return 0;
return (cotisationsPayees / totalCotisations * 100);
}
/// Calcule le pourcentage de cotisations en retard
double get pourcentageCotisationsEnRetard {
if (totalCotisations == 0) return 0;
return (cotisationsEnRetard / totalCotisations * 100);
}
/// Calcule le pourcentage de cotisations en attente
double get pourcentageCotisationsEnAttente {
if (totalCotisations == 0) return 0;
return (cotisationsEnAttente / totalCotisations * 100);
}
/// Retourne le statut de santé financière
String get statutSanteFinanciere {
if (tauxPaiement >= 90) return 'EXCELLENT';
if (tauxPaiement >= 75) return 'BON';
if (tauxPaiement >= 60) return 'MOYEN';
if (tauxPaiement >= 40) return 'FAIBLE';
return 'CRITIQUE';
}
/// Retourne la couleur associée au statut de santé
String get couleurSanteFinanciere {
switch (statutSanteFinanciere) {
case 'EXCELLENT':
return '#4CAF50'; // Vert
case 'BON':
return '#8BC34A'; // Vert clair
case 'MOYEN':
return '#FF9800'; // Orange
case 'FAIBLE':
return '#FF5722'; // Orange foncé
case 'CRITIQUE':
return '#F44336'; // Rouge
default:
return '#757575'; // Gris
}
}
/// Retourne le libellé du statut de santé
String get libelleSanteFinanciere {
switch (statutSanteFinanciere) {
case 'EXCELLENT':
return 'Excellente santé financière';
case 'BON':
return 'Bonne santé financière';
case 'MOYEN':
return 'Santé financière moyenne';
case 'FAIBLE':
return 'Santé financière faible';
case 'CRITIQUE':
return 'Situation critique';
default:
return 'Statut inconnu';
}
}
/// Calcule la progression par rapport à la période précédente
double? calculerProgression(CotisationStatisticsModel? precedent) {
if (precedent == null || precedent.montantPaye == 0) return null;
return ((montantPaye - precedent.montantPaye) / precedent.montantPaye * 100);
}
/// Retourne les indicateurs clés de performance
Map<String, dynamic> get kpis {
return {
'tauxRecouvrement': tauxPaiement,
'tauxRetard': tauxRetard,
'montantMoyenCotisation': montantMoyenCotisation,
'montantMoyenPaiement': montantMoyenPaiement,
'efficaciteRecouvrement': montantPaye / montantTotal * 100,
'risqueImpaye': montantRestant / montantTotal * 100,
};
}
/// Retourne les alertes basées sur les seuils
List<String> get alertes {
List<String> alertes = [];
if (tauxRetard > 20) {
alertes.add('Taux de retard élevé (${tauxRetard.toStringAsFixed(1)}%)');
}
if (tauxPaiement < 60) {
alertes.add('Taux de paiement faible (${tauxPaiement.toStringAsFixed(1)}%)');
}
if (cotisationsEnRetard > totalCotisations * 0.3) {
alertes.add('Trop de cotisations en retard ($cotisationsEnRetard)');
}
if (montantRestant > montantTotal * 0.4) {
alertes.add('Montant impayé important (${montantRestant.toStringAsFixed(0)} XOF)');
}
return alertes;
}
/// Vérifie si des actions sont nécessaires
bool get actionRequise => alertes.isNotEmpty;
/// Retourne les recommandations d'amélioration
List<String> get recommandations {
List<String> recommandations = [];
if (tauxRetard > 15) {
recommandations.add('Mettre en place des rappels automatiques');
recommandations.add('Contacter les membres en retard');
}
if (tauxPaiement < 70) {
recommandations.add('Faciliter les moyens de paiement');
recommandations.add('Proposer des échéanciers personnalisés');
}
if (cotisationsEnRetard > 10) {
recommandations.add('Organiser une campagne de recouvrement');
}
return recommandations;
}
/// Copie avec modifications
CotisationStatisticsModel copyWith({
int? totalCotisations,
double? montantTotal,
double? montantPaye,
double? montantRestant,
int? cotisationsPayees,
int? cotisationsEnAttente,
int? cotisationsEnRetard,
int? cotisationsAnnulees,
double? tauxPaiement,
double? tauxRetard,
double? montantMoyenCotisation,
double? montantMoyenPaiement,
Map<String, int>? repartitionParType,
Map<String, double>? montantParType,
Map<String, int>? repartitionParStatut,
Map<String, double>? montantParStatut,
Map<String, int>? evolutionMensuelle,
Map<String, double>? chiffreAffaireMensuel,
List<CotisationTrendModel>? tendances,
DateTime? dateCalcul,
String? periode,
int? annee,
int? mois,
}) {
return CotisationStatisticsModel(
totalCotisations: totalCotisations ?? this.totalCotisations,
montantTotal: montantTotal ?? this.montantTotal,
montantPaye: montantPaye ?? this.montantPaye,
montantRestant: montantRestant ?? this.montantRestant,
cotisationsPayees: cotisationsPayees ?? this.cotisationsPayees,
cotisationsEnAttente: cotisationsEnAttente ?? this.cotisationsEnAttente,
cotisationsEnRetard: cotisationsEnRetard ?? this.cotisationsEnRetard,
cotisationsAnnulees: cotisationsAnnulees ?? this.cotisationsAnnulees,
tauxPaiement: tauxPaiement ?? this.tauxPaiement,
tauxRetard: tauxRetard ?? this.tauxRetard,
montantMoyenCotisation: montantMoyenCotisation ?? this.montantMoyenCotisation,
montantMoyenPaiement: montantMoyenPaiement ?? this.montantMoyenPaiement,
repartitionParType: repartitionParType ?? this.repartitionParType,
montantParType: montantParType ?? this.montantParType,
repartitionParStatut: repartitionParStatut ?? this.repartitionParStatut,
montantParStatut: montantParStatut ?? this.montantParStatut,
evolutionMensuelle: evolutionMensuelle ?? this.evolutionMensuelle,
chiffreAffaireMensuel: chiffreAffaireMensuel ?? this.chiffreAffaireMensuel,
tendances: tendances ?? this.tendances,
dateCalcul: dateCalcul ?? this.dateCalcul,
periode: periode ?? this.periode,
annee: annee ?? this.annee,
mois: mois ?? this.mois,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CotisationStatisticsModel &&
other.dateCalcul == dateCalcul &&
other.periode == periode &&
other.annee == annee &&
other.mois == mois;
}
@override
int get hashCode => Object.hash(dateCalcul, periode, annee, mois);
@override
String toString() {
return 'CotisationStatisticsModel(totalCotisations: $totalCotisations, '
'montantTotal: $montantTotal, tauxPaiement: $tauxPaiement%)';
}
}
/// Modèle pour les tendances des cotisations
@JsonSerializable()
class CotisationTrendModel {
final String periode;
final int totalCotisations;
final double montantTotal;
final double montantPaye;
final double tauxPaiement;
final DateTime date;
const CotisationTrendModel({
required this.periode,
required this.totalCotisations,
required this.montantTotal,
required this.montantPaye,
required this.tauxPaiement,
required this.date,
});
factory CotisationTrendModel.fromJson(Map<String, dynamic> json) =>
_$CotisationTrendModelFromJson(json);
Map<String, dynamic> toJson() => _$CotisationTrendModelToJson(this);
@override
String toString() {
return 'CotisationTrendModel(periode: $periode, tauxPaiement: $tauxPaiement%)';
}
}

View File

@@ -1,105 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cotisation_statistics_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CotisationStatisticsModel _$CotisationStatisticsModelFromJson(
Map<String, dynamic> json) =>
CotisationStatisticsModel(
totalCotisations: (json['totalCotisations'] as num).toInt(),
montantTotal: (json['montantTotal'] as num).toDouble(),
montantPaye: (json['montantPaye'] as num).toDouble(),
montantRestant: (json['montantRestant'] as num).toDouble(),
cotisationsPayees: (json['cotisationsPayees'] as num).toInt(),
cotisationsEnAttente: (json['cotisationsEnAttente'] as num).toInt(),
cotisationsEnRetard: (json['cotisationsEnRetard'] as num).toInt(),
cotisationsAnnulees: (json['cotisationsAnnulees'] as num).toInt(),
tauxPaiement: (json['tauxPaiement'] as num).toDouble(),
tauxRetard: (json['tauxRetard'] as num).toDouble(),
montantMoyenCotisation:
(json['montantMoyenCotisation'] as num).toDouble(),
montantMoyenPaiement: (json['montantMoyenPaiement'] as num).toDouble(),
repartitionParType:
(json['repartitionParType'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
),
montantParType: (json['montantParType'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toDouble()),
),
repartitionParStatut:
(json['repartitionParStatut'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
),
montantParStatut:
(json['montantParStatut'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toDouble()),
),
evolutionMensuelle:
(json['evolutionMensuelle'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
),
chiffreAffaireMensuel:
(json['chiffreAffaireMensuel'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toDouble()),
),
tendances: (json['tendances'] as List<dynamic>?)
?.map((e) => CotisationTrendModel.fromJson(e as Map<String, dynamic>))
.toList(),
dateCalcul: DateTime.parse(json['dateCalcul'] as String),
periode: json['periode'] as String?,
annee: (json['annee'] as num?)?.toInt(),
mois: (json['mois'] as num?)?.toInt(),
);
Map<String, dynamic> _$CotisationStatisticsModelToJson(
CotisationStatisticsModel instance) =>
<String, dynamic>{
'totalCotisations': instance.totalCotisations,
'montantTotal': instance.montantTotal,
'montantPaye': instance.montantPaye,
'montantRestant': instance.montantRestant,
'cotisationsPayees': instance.cotisationsPayees,
'cotisationsEnAttente': instance.cotisationsEnAttente,
'cotisationsEnRetard': instance.cotisationsEnRetard,
'cotisationsAnnulees': instance.cotisationsAnnulees,
'tauxPaiement': instance.tauxPaiement,
'tauxRetard': instance.tauxRetard,
'montantMoyenCotisation': instance.montantMoyenCotisation,
'montantMoyenPaiement': instance.montantMoyenPaiement,
'repartitionParType': instance.repartitionParType,
'montantParType': instance.montantParType,
'repartitionParStatut': instance.repartitionParStatut,
'montantParStatut': instance.montantParStatut,
'evolutionMensuelle': instance.evolutionMensuelle,
'chiffreAffaireMensuel': instance.chiffreAffaireMensuel,
'tendances': instance.tendances,
'dateCalcul': instance.dateCalcul.toIso8601String(),
'periode': instance.periode,
'annee': instance.annee,
'mois': instance.mois,
};
CotisationTrendModel _$CotisationTrendModelFromJson(
Map<String, dynamic> json) =>
CotisationTrendModel(
periode: json['periode'] as String,
totalCotisations: (json['totalCotisations'] as num).toInt(),
montantTotal: (json['montantTotal'] as num).toDouble(),
montantPaye: (json['montantPaye'] as num).toDouble(),
tauxPaiement: (json['tauxPaiement'] as num).toDouble(),
date: DateTime.parse(json['date'] as String),
);
Map<String, dynamic> _$CotisationTrendModelToJson(
CotisationTrendModel instance) =>
<String, dynamic>{
'periode': instance.periode,
'totalCotisations': instance.totalCotisations,
'montantTotal': instance.montantTotal,
'montantPaye': instance.montantPaye,
'tauxPaiement': instance.tauxPaiement,
'date': instance.date.toIso8601String(),
};

View File

@@ -1,391 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'evenement_model.g.dart';
/// Modèle de données pour un événement UnionFlow
/// Aligné avec l'entité Evenement du serveur API
@JsonSerializable()
class EvenementModel extends Equatable {
/// ID unique de l'événement
final String? id;
/// Titre de l'événement
final String titre;
/// Description détaillée
final String? description;
/// Date et heure de début
@JsonKey(name: 'dateDebut')
final DateTime dateDebut;
/// Date et heure de fin
@JsonKey(name: 'dateFin')
final DateTime? dateFin;
/// Lieu de l'événement
final String? lieu;
/// Adresse complète
final String? adresse;
/// Type d'événement
@JsonKey(name: 'typeEvenement')
final TypeEvenement typeEvenement;
/// Statut de l'événement
final StatutEvenement statut;
/// Capacité maximale
@JsonKey(name: 'capaciteMax')
final int? capaciteMax;
/// Prix de participation
final double? prix;
/// Inscription requise
@JsonKey(name: 'inscriptionRequise')
final bool inscriptionRequise;
/// Date limite d'inscription
@JsonKey(name: 'dateLimiteInscription')
final DateTime? dateLimiteInscription;
/// Instructions particulières
@JsonKey(name: 'instructionsParticulieres')
final String? instructionsParticulieres;
/// Contact organisateur
@JsonKey(name: 'contactOrganisateur')
final String? contactOrganisateur;
/// Matériel requis
@JsonKey(name: 'materielRequis')
final String? materielRequis;
/// Visible au public
@JsonKey(name: 'visiblePublic')
final bool visiblePublic;
/// Événement actif
final bool actif;
/// Créé par
@JsonKey(name: 'creePar')
final String? creePar;
/// Date de création
@JsonKey(name: 'dateCreation')
final DateTime? dateCreation;
/// Modifié par
@JsonKey(name: 'modifiePar')
final String? modifiePar;
/// Date de modification
@JsonKey(name: 'dateModification')
final DateTime? dateModification;
/// Organisation associée (ID)
@JsonKey(name: 'organisationId')
final String? organisationId;
/// Organisateur (ID)
@JsonKey(name: 'organisateurId')
final String? organisateurId;
const EvenementModel({
this.id,
required this.titre,
this.description,
required this.dateDebut,
this.dateFin,
this.lieu,
this.adresse,
required this.typeEvenement,
required this.statut,
this.capaciteMax,
this.prix,
required this.inscriptionRequise,
this.dateLimiteInscription,
this.instructionsParticulieres,
this.contactOrganisateur,
this.materielRequis,
required this.visiblePublic,
required this.actif,
this.creePar,
this.dateCreation,
this.modifiePar,
this.dateModification,
this.organisationId,
this.organisateurId,
});
/// Factory pour créer depuis JSON
factory EvenementModel.fromJson(Map<String, dynamic> json) =>
_$EvenementModelFromJson(json);
/// Convertir vers JSON
Map<String, dynamic> toJson() => _$EvenementModelToJson(this);
/// Copie avec modifications
EvenementModel copyWith({
String? id,
String? titre,
String? description,
DateTime? dateDebut,
DateTime? dateFin,
String? lieu,
String? adresse,
TypeEvenement? typeEvenement,
StatutEvenement? statut,
int? capaciteMax,
double? prix,
bool? inscriptionRequise,
DateTime? dateLimiteInscription,
String? instructionsParticulieres,
String? contactOrganisateur,
String? materielRequis,
bool? visiblePublic,
bool? actif,
String? creePar,
DateTime? dateCreation,
String? modifiePar,
DateTime? dateModification,
String? organisationId,
String? organisateurId,
}) {
return EvenementModel(
id: id ?? this.id,
titre: titre ?? this.titre,
description: description ?? this.description,
dateDebut: dateDebut ?? this.dateDebut,
dateFin: dateFin ?? this.dateFin,
lieu: lieu ?? this.lieu,
adresse: adresse ?? this.adresse,
typeEvenement: typeEvenement ?? this.typeEvenement,
statut: statut ?? this.statut,
capaciteMax: capaciteMax ?? this.capaciteMax,
prix: prix ?? this.prix,
inscriptionRequise: inscriptionRequise ?? this.inscriptionRequise,
dateLimiteInscription: dateLimiteInscription ?? this.dateLimiteInscription,
instructionsParticulieres: instructionsParticulieres ?? this.instructionsParticulieres,
contactOrganisateur: contactOrganisateur ?? this.contactOrganisateur,
materielRequis: materielRequis ?? this.materielRequis,
visiblePublic: visiblePublic ?? this.visiblePublic,
actif: actif ?? this.actif,
creePar: creePar ?? this.creePar,
dateCreation: dateCreation ?? this.dateCreation,
modifiePar: modifiePar ?? this.modifiePar,
dateModification: dateModification ?? this.dateModification,
organisationId: organisationId ?? this.organisationId,
organisateurId: organisateurId ?? this.organisateurId,
);
}
/// Méthodes utilitaires
/// Vérifie si l'événement est à venir
bool get estAVenir => dateDebut.isAfter(DateTime.now());
/// Vérifie si l'événement est en cours
bool get estEnCours {
final maintenant = DateTime.now();
return dateDebut.isBefore(maintenant) &&
(dateFin?.isAfter(maintenant) ?? false);
}
/// Vérifie si l'événement est terminé
bool get estTermine {
final maintenant = DateTime.now();
return dateFin?.isBefore(maintenant) ?? dateDebut.isBefore(maintenant);
}
/// Vérifie si les inscriptions sont ouvertes
bool get inscriptionsOuvertes {
if (!inscriptionRequise) return false;
if (dateLimiteInscription == null) return estAVenir;
return dateLimiteInscription!.isAfter(DateTime.now()) && estAVenir;
}
/// Durée de l'événement
Duration? get duree {
if (dateFin == null) return null;
return dateFin!.difference(dateDebut);
}
/// Formatage de la durée
String get dureeFormatee {
final d = duree;
if (d == null) return 'Non spécifiée';
if (d.inDays > 0) {
return '${d.inDays} jour${d.inDays > 1 ? 's' : ''}';
} else if (d.inHours > 0) {
return '${d.inHours}h${d.inMinutes.remainder(60) > 0 ? '${d.inMinutes.remainder(60)}' : ''}';
} else {
return '${d.inMinutes} min';
}
}
@override
List<Object?> get props => [
id,
titre,
description,
dateDebut,
dateFin,
lieu,
adresse,
typeEvenement,
statut,
capaciteMax,
prix,
inscriptionRequise,
dateLimiteInscription,
instructionsParticulieres,
contactOrganisateur,
materielRequis,
visiblePublic,
actif,
creePar,
dateCreation,
modifiePar,
dateModification,
organisationId,
organisateurId,
];
}
/// Types d'événements disponibles
@JsonEnum()
enum TypeEvenement {
@JsonValue('ASSEMBLEE_GENERALE')
assembleeGenerale,
@JsonValue('REUNION')
reunion,
@JsonValue('FORMATION')
formation,
@JsonValue('CONFERENCE')
conference,
@JsonValue('ATELIER')
atelier,
@JsonValue('SEMINAIRE')
seminaire,
@JsonValue('EVENEMENT_SOCIAL')
evenementSocial,
@JsonValue('MANIFESTATION')
manifestation,
@JsonValue('CELEBRATION')
celebration,
@JsonValue('AUTRE')
autre,
}
/// Extension pour les libellés des types
extension TypeEvenementExtension on TypeEvenement {
String get libelle {
switch (this) {
case TypeEvenement.assembleeGenerale:
return 'Assemblée Générale';
case TypeEvenement.reunion:
return 'Réunion';
case TypeEvenement.formation:
return 'Formation';
case TypeEvenement.conference:
return 'Conférence';
case TypeEvenement.atelier:
return 'Atelier';
case TypeEvenement.seminaire:
return 'Séminaire';
case TypeEvenement.evenementSocial:
return 'Événement Social';
case TypeEvenement.manifestation:
return 'Manifestation';
case TypeEvenement.celebration:
return 'Célébration';
case TypeEvenement.autre:
return 'Autre';
}
}
String get icone {
switch (this) {
case TypeEvenement.assembleeGenerale:
return '🏛️';
case TypeEvenement.reunion:
return '👥';
case TypeEvenement.formation:
return '📚';
case TypeEvenement.conference:
return '🎤';
case TypeEvenement.atelier:
return '🔧';
case TypeEvenement.seminaire:
return '🎓';
case TypeEvenement.evenementSocial:
return '🎉';
case TypeEvenement.manifestation:
return '📢';
case TypeEvenement.celebration:
return '🎊';
case TypeEvenement.autre:
return '📅';
}
}
}
/// Statuts d'événements disponibles
@JsonEnum()
enum StatutEvenement {
@JsonValue('PLANIFIE')
planifie,
@JsonValue('CONFIRME')
confirme,
@JsonValue('EN_COURS')
enCours,
@JsonValue('TERMINE')
termine,
@JsonValue('ANNULE')
annule,
@JsonValue('REPORTE')
reporte,
}
/// Extension pour les libellés des statuts
extension StatutEvenementExtension on StatutEvenement {
String get libelle {
switch (this) {
case StatutEvenement.planifie:
return 'Planifié';
case StatutEvenement.confirme:
return 'Confirmé';
case StatutEvenement.enCours:
return 'En cours';
case StatutEvenement.termine:
return 'Terminé';
case StatutEvenement.annule:
return 'Annulé';
case StatutEvenement.reporte:
return 'Reporté';
}
}
String get couleur {
switch (this) {
case StatutEvenement.planifie:
return '#FFA500'; // Orange
case StatutEvenement.confirme:
return '#4CAF50'; // Vert
case StatutEvenement.enCours:
return '#2196F3'; // Bleu
case StatutEvenement.termine:
return '#9E9E9E'; // Gris
case StatutEvenement.annule:
return '#F44336'; // Rouge
case StatutEvenement.reporte:
return '#FF9800'; // Orange foncé
}
}
}

View File

@@ -1,94 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'evenement_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
EvenementModel _$EvenementModelFromJson(Map<String, dynamic> json) =>
EvenementModel(
id: json['id'] as String?,
titre: json['titre'] as String,
description: json['description'] as String?,
dateDebut: DateTime.parse(json['dateDebut'] as String),
dateFin: json['dateFin'] == null
? null
: DateTime.parse(json['dateFin'] as String),
lieu: json['lieu'] as String?,
adresse: json['adresse'] as String?,
typeEvenement: $enumDecode(_$TypeEvenementEnumMap, json['typeEvenement']),
statut: $enumDecode(_$StatutEvenementEnumMap, json['statut']),
capaciteMax: (json['capaciteMax'] as num?)?.toInt(),
prix: (json['prix'] as num?)?.toDouble(),
inscriptionRequise: json['inscriptionRequise'] as bool,
dateLimiteInscription: json['dateLimiteInscription'] == null
? null
: DateTime.parse(json['dateLimiteInscription'] as String),
instructionsParticulieres: json['instructionsParticulieres'] as String?,
contactOrganisateur: json['contactOrganisateur'] as String?,
materielRequis: json['materielRequis'] as String?,
visiblePublic: json['visiblePublic'] as bool,
actif: json['actif'] as bool,
creePar: json['creePar'] as String?,
dateCreation: json['dateCreation'] == null
? null
: DateTime.parse(json['dateCreation'] as String),
modifiePar: json['modifiePar'] as String?,
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
organisationId: json['organisationId'] as String?,
organisateurId: json['organisateurId'] as String?,
);
Map<String, dynamic> _$EvenementModelToJson(EvenementModel instance) =>
<String, dynamic>{
'id': instance.id,
'titre': instance.titre,
'description': instance.description,
'dateDebut': instance.dateDebut.toIso8601String(),
'dateFin': instance.dateFin?.toIso8601String(),
'lieu': instance.lieu,
'adresse': instance.adresse,
'typeEvenement': _$TypeEvenementEnumMap[instance.typeEvenement]!,
'statut': _$StatutEvenementEnumMap[instance.statut]!,
'capaciteMax': instance.capaciteMax,
'prix': instance.prix,
'inscriptionRequise': instance.inscriptionRequise,
'dateLimiteInscription':
instance.dateLimiteInscription?.toIso8601String(),
'instructionsParticulieres': instance.instructionsParticulieres,
'contactOrganisateur': instance.contactOrganisateur,
'materielRequis': instance.materielRequis,
'visiblePublic': instance.visiblePublic,
'actif': instance.actif,
'creePar': instance.creePar,
'dateCreation': instance.dateCreation?.toIso8601String(),
'modifiePar': instance.modifiePar,
'dateModification': instance.dateModification?.toIso8601String(),
'organisationId': instance.organisationId,
'organisateurId': instance.organisateurId,
};
const _$TypeEvenementEnumMap = {
TypeEvenement.assembleeGenerale: 'ASSEMBLEE_GENERALE',
TypeEvenement.reunion: 'REUNION',
TypeEvenement.formation: 'FORMATION',
TypeEvenement.conference: 'CONFERENCE',
TypeEvenement.atelier: 'ATELIER',
TypeEvenement.seminaire: 'SEMINAIRE',
TypeEvenement.evenementSocial: 'EVENEMENT_SOCIAL',
TypeEvenement.manifestation: 'MANIFESTATION',
TypeEvenement.celebration: 'CELEBRATION',
TypeEvenement.autre: 'AUTRE',
};
const _$StatutEvenementEnumMap = {
StatutEvenement.planifie: 'PLANIFIE',
StatutEvenement.confirme: 'CONFIRME',
StatutEvenement.enCours: 'EN_COURS',
StatutEvenement.termine: 'TERMINE',
StatutEvenement.annule: 'ANNULE',
StatutEvenement.reporte: 'REPORTE',
};

View File

@@ -1,212 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'membre_model.g.dart';
/// Modèle de données pour un membre UnionFlow
/// Aligné avec MembreDTO du serveur API
@JsonSerializable()
class MembreModel extends Equatable {
/// ID unique du membre
final String? id;
/// Numéro unique du membre (format: UF-YYYY-XXXXXXXX)
@JsonKey(name: 'numeroMembre')
final String numeroMembre;
/// Nom de famille du membre
final String nom;
/// Prénom du membre
final String prenom;
/// Adresse email
final String email;
/// Numéro de téléphone
final String telephone;
/// Date de naissance
@JsonKey(name: 'dateNaissance')
final DateTime? dateNaissance;
/// Adresse complète
final String? adresse;
/// Ville
final String? ville;
/// Code postal
@JsonKey(name: 'codePostal')
final String? codePostal;
/// Pays
final String? pays;
/// Profession
final String? profession;
/// Statut du membre (ACTIF, INACTIF, SUSPENDU)
final String statut;
/// Date d'adhésion
@JsonKey(name: 'dateAdhesion')
final DateTime dateAdhesion;
/// Date de création
@JsonKey(name: 'dateCreation')
final DateTime dateCreation;
/// Date de dernière modification
@JsonKey(name: 'dateModification')
final DateTime? dateModification;
/// Indique si le membre est actif
final bool actif;
/// Version pour optimistic locking
final int version;
const MembreModel({
this.id,
required this.numeroMembre,
required this.nom,
required this.prenom,
required this.email,
required this.telephone,
this.dateNaissance,
this.adresse,
this.ville,
this.codePostal,
this.pays,
this.profession,
required this.statut,
required this.dateAdhesion,
required this.dateCreation,
this.dateModification,
required this.actif,
required this.version,
});
/// Constructeur depuis JSON
factory MembreModel.fromJson(Map<String, dynamic> json) =>
_$MembreModelFromJson(json);
/// Conversion vers JSON
Map<String, dynamic> toJson() => _$MembreModelToJson(this);
/// Nom complet du membre
String get nomComplet => '$prenom $nom';
/// Initiales du membre
String get initiales {
final prenomInitial = prenom.isNotEmpty ? prenom[0].toUpperCase() : '';
final nomInitial = nom.isNotEmpty ? nom[0].toUpperCase() : '';
return '$prenomInitial$nomInitial';
}
/// Adresse complète formatée
String get adresseComplete {
final parts = <String>[];
if (adresse?.isNotEmpty == true) parts.add(adresse!);
if (ville?.isNotEmpty == true) parts.add(ville!);
if (codePostal?.isNotEmpty == true) parts.add(codePostal!);
if (pays?.isNotEmpty == true) parts.add(pays!);
return parts.join(', ');
}
/// Libellé du statut formaté
String get statutLibelle {
switch (statut.toUpperCase()) {
case 'ACTIF':
return 'Actif';
case 'INACTIF':
return 'Inactif';
case 'SUSPENDU':
return 'Suspendu';
default:
return statut;
}
}
/// Âge calculé à partir de la date de naissance
int get age {
if (dateNaissance == null) return 0;
final now = DateTime.now();
int age = now.year - dateNaissance!.year;
if (now.month < dateNaissance!.month ||
(now.month == dateNaissance!.month && now.day < dateNaissance!.day)) {
age--;
}
return age;
}
/// Copie avec modifications
MembreModel copyWith({
String? id,
String? numeroMembre,
String? nom,
String? prenom,
String? email,
String? telephone,
DateTime? dateNaissance,
String? adresse,
String? ville,
String? codePostal,
String? pays,
String? profession,
String? statut,
DateTime? dateAdhesion,
DateTime? dateCreation,
DateTime? dateModification,
bool? actif,
int? version,
}) {
return MembreModel(
id: id ?? this.id,
numeroMembre: numeroMembre ?? this.numeroMembre,
nom: nom ?? this.nom,
prenom: prenom ?? this.prenom,
email: email ?? this.email,
telephone: telephone ?? this.telephone,
dateNaissance: dateNaissance ?? this.dateNaissance,
adresse: adresse ?? this.adresse,
ville: ville ?? this.ville,
codePostal: codePostal ?? this.codePostal,
pays: pays ?? this.pays,
profession: profession ?? this.profession,
statut: statut ?? this.statut,
dateAdhesion: dateAdhesion ?? this.dateAdhesion,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
actif: actif ?? this.actif,
version: version ?? this.version,
);
}
@override
List<Object?> get props => [
id,
numeroMembre,
nom,
prenom,
email,
telephone,
dateNaissance,
adresse,
ville,
codePostal,
pays,
profession,
statut,
dateAdhesion,
dateCreation,
dateModification,
actif,
version,
];
@override
String toString() => 'MembreModel(id: $id, numeroMembre: $numeroMembre, '
'nomComplet: $nomComplet, email: $email, statut: $statut)';
}

View File

@@ -1,54 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'membre_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MembreModel _$MembreModelFromJson(Map<String, dynamic> json) => MembreModel(
id: json['id'] as String?,
numeroMembre: json['numeroMembre'] as String,
nom: json['nom'] as String,
prenom: json['prenom'] as String,
email: json['email'] as String,
telephone: json['telephone'] as String,
dateNaissance: json['dateNaissance'] == null
? null
: DateTime.parse(json['dateNaissance'] as String),
adresse: json['adresse'] as String?,
ville: json['ville'] as String?,
codePostal: json['codePostal'] as String?,
pays: json['pays'] as String?,
profession: json['profession'] as String?,
statut: json['statut'] as String,
dateAdhesion: DateTime.parse(json['dateAdhesion'] as String),
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
actif: json['actif'] as bool,
version: (json['version'] as num).toInt(),
);
Map<String, dynamic> _$MembreModelToJson(MembreModel instance) =>
<String, dynamic>{
'id': instance.id,
'numeroMembre': instance.numeroMembre,
'nom': instance.nom,
'prenom': instance.prenom,
'email': instance.email,
'telephone': instance.telephone,
'dateNaissance': instance.dateNaissance?.toIso8601String(),
'adresse': instance.adresse,
'ville': instance.ville,
'codePostal': instance.codePostal,
'pays': instance.pays,
'profession': instance.profession,
'statut': instance.statut,
'dateAdhesion': instance.dateAdhesion.toIso8601String(),
'dateCreation': instance.dateCreation.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
'actif': instance.actif,
'version': instance.version,
};

View File

@@ -1,279 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'payment_model.g.dart';
/// Modèle de données pour les paiements
/// Représente une transaction de paiement de cotisation
@JsonSerializable()
class PaymentModel {
final String id;
final String cotisationId;
final String numeroReference;
final double montant;
final String codeDevise;
final String methodePaiement;
final String statut;
final DateTime dateTransaction;
final String? numeroTransaction;
final String? referencePaiement;
final String? description;
final Map<String, dynamic>? metadonnees;
final String? operateurMobileMoney;
final String? numeroTelephone;
final String? nomPayeur;
final String? emailPayeur;
final double? fraisTransaction;
final String? codeAutorisation;
final String? messageErreur;
final int? nombreTentatives;
final DateTime? dateEcheance;
final DateTime dateCreation;
final DateTime? dateModification;
const PaymentModel({
required this.id,
required this.cotisationId,
required this.numeroReference,
required this.montant,
required this.codeDevise,
required this.methodePaiement,
required this.statut,
required this.dateTransaction,
this.numeroTransaction,
this.referencePaiement,
this.description,
this.metadonnees,
this.operateurMobileMoney,
this.numeroTelephone,
this.nomPayeur,
this.emailPayeur,
this.fraisTransaction,
this.codeAutorisation,
this.messageErreur,
this.nombreTentatives,
this.dateEcheance,
required this.dateCreation,
this.dateModification,
});
/// Factory pour créer depuis JSON
factory PaymentModel.fromJson(Map<String, dynamic> json) =>
_$PaymentModelFromJson(json);
/// Convertit vers JSON
Map<String, dynamic> toJson() => _$PaymentModelToJson(this);
/// Vérifie si le paiement est réussi
bool get isSuccessful => statut == 'COMPLETED' || statut == 'SUCCESS';
/// Vérifie si le paiement est en cours
bool get isPending => statut == 'PENDING' || statut == 'PROCESSING';
/// Vérifie si le paiement a échoué
bool get isFailed => statut == 'FAILED' || statut == 'ERROR' || statut == 'CANCELLED';
/// Retourne la couleur associée au statut
String get couleurStatut {
switch (statut) {
case 'COMPLETED':
case 'SUCCESS':
return '#4CAF50'; // Vert
case 'PENDING':
case 'PROCESSING':
return '#FF9800'; // Orange
case 'FAILED':
case 'ERROR':
return '#F44336'; // Rouge
case 'CANCELLED':
return '#9E9E9E'; // Gris
default:
return '#757575'; // Gris foncé
}
}
/// Retourne le libellé du statut en français
String get libelleStatut {
switch (statut) {
case 'COMPLETED':
case 'SUCCESS':
return 'Réussi';
case 'PENDING':
return 'En attente';
case 'PROCESSING':
return 'En cours';
case 'FAILED':
return 'Échoué';
case 'ERROR':
return 'Erreur';
case 'CANCELLED':
return 'Annulé';
default:
return statut;
}
}
/// Retourne le libellé de la méthode de paiement
String get libelleMethodePaiement {
switch (methodePaiement) {
case 'MOBILE_MONEY':
return 'Mobile Money';
case 'ORANGE_MONEY':
return 'Orange Money';
case 'WAVE':
return 'Wave';
case 'MOOV_MONEY':
return 'Moov Money';
case 'CARTE_BANCAIRE':
return 'Carte bancaire';
case 'VIREMENT':
return 'Virement bancaire';
case 'ESPECES':
return 'Espèces';
case 'CHEQUE':
return 'Chèque';
default:
return methodePaiement;
}
}
/// Retourne l'icône associée à la méthode de paiement
String get iconeMethodePaiement {
switch (methodePaiement) {
case 'MOBILE_MONEY':
case 'ORANGE_MONEY':
case 'WAVE':
case 'MOOV_MONEY':
return '📱';
case 'CARTE_BANCAIRE':
return '💳';
case 'VIREMENT':
return '🏦';
case 'ESPECES':
return '💵';
case 'CHEQUE':
return '📝';
default:
return '💰';
}
}
/// Calcule le montant net (montant - frais)
double get montantNet {
return montant - (fraisTransaction ?? 0);
}
/// Vérifie si des frais sont appliqués
bool get hasFrais => fraisTransaction != null && fraisTransaction! > 0;
/// Retourne le pourcentage de frais
double get pourcentageFrais {
if (montant == 0 || fraisTransaction == null) return 0;
return (fraisTransaction! / montant * 100);
}
/// Vérifie si le paiement est expiré
bool get isExpired {
if (dateEcheance == null) return false;
return DateTime.now().isAfter(dateEcheance!) && !isSuccessful;
}
/// Retourne le temps restant avant expiration
Duration? get tempsRestant {
if (dateEcheance == null || isExpired) return null;
return dateEcheance!.difference(DateTime.now());
}
/// Retourne un message d'état détaillé
String get messageStatut {
switch (statut) {
case 'COMPLETED':
case 'SUCCESS':
return 'Paiement effectué avec succès';
case 'PENDING':
return 'Paiement en attente de confirmation';
case 'PROCESSING':
return 'Traitement du paiement en cours';
case 'FAILED':
return messageErreur ?? 'Le paiement a échoué';
case 'ERROR':
return messageErreur ?? 'Erreur lors du paiement';
case 'CANCELLED':
return 'Paiement annulé par l\'utilisateur';
default:
return 'Statut inconnu';
}
}
/// Vérifie si le paiement peut être retenté
bool get canRetry {
return isFailed && (nombreTentatives ?? 0) < 3 && !isExpired;
}
/// Copie avec modifications
PaymentModel copyWith({
String? id,
String? cotisationId,
String? numeroReference,
double? montant,
String? codeDevise,
String? methodePaiement,
String? statut,
DateTime? dateTransaction,
String? numeroTransaction,
String? referencePaiement,
String? description,
Map<String, dynamic>? metadonnees,
String? operateurMobileMoney,
String? numeroTelephone,
String? nomPayeur,
String? emailPayeur,
double? fraisTransaction,
String? codeAutorisation,
String? messageErreur,
int? nombreTentatives,
DateTime? dateEcheance,
DateTime? dateCreation,
DateTime? dateModification,
}) {
return PaymentModel(
id: id ?? this.id,
cotisationId: cotisationId ?? this.cotisationId,
numeroReference: numeroReference ?? this.numeroReference,
montant: montant ?? this.montant,
codeDevise: codeDevise ?? this.codeDevise,
methodePaiement: methodePaiement ?? this.methodePaiement,
statut: statut ?? this.statut,
dateTransaction: dateTransaction ?? this.dateTransaction,
numeroTransaction: numeroTransaction ?? this.numeroTransaction,
referencePaiement: referencePaiement ?? this.referencePaiement,
description: description ?? this.description,
metadonnees: metadonnees ?? this.metadonnees,
operateurMobileMoney: operateurMobileMoney ?? this.operateurMobileMoney,
numeroTelephone: numeroTelephone ?? this.numeroTelephone,
nomPayeur: nomPayeur ?? this.nomPayeur,
emailPayeur: emailPayeur ?? this.emailPayeur,
fraisTransaction: fraisTransaction ?? this.fraisTransaction,
codeAutorisation: codeAutorisation ?? this.codeAutorisation,
messageErreur: messageErreur ?? this.messageErreur,
nombreTentatives: nombreTentatives ?? this.nombreTentatives,
dateEcheance: dateEcheance ?? this.dateEcheance,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PaymentModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
@override
String toString() {
return 'PaymentModel(id: $id, numeroReference: $numeroReference, '
'montant: $montant, methodePaiement: $methodePaiement, statut: $statut)';
}
}

View File

@@ -1,64 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payment_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PaymentModel _$PaymentModelFromJson(Map<String, dynamic> json) => PaymentModel(
id: json['id'] as String,
cotisationId: json['cotisationId'] as String,
numeroReference: json['numeroReference'] as String,
montant: (json['montant'] as num).toDouble(),
codeDevise: json['codeDevise'] as String,
methodePaiement: json['methodePaiement'] as String,
statut: json['statut'] as String,
dateTransaction: DateTime.parse(json['dateTransaction'] as String),
numeroTransaction: json['numeroTransaction'] as String?,
referencePaiement: json['referencePaiement'] as String?,
description: json['description'] as String?,
metadonnees: json['metadonnees'] as Map<String, dynamic>?,
operateurMobileMoney: json['operateurMobileMoney'] as String?,
numeroTelephone: json['numeroTelephone'] as String?,
nomPayeur: json['nomPayeur'] as String?,
emailPayeur: json['emailPayeur'] as String?,
fraisTransaction: (json['fraisTransaction'] as num?)?.toDouble(),
codeAutorisation: json['codeAutorisation'] as String?,
messageErreur: json['messageErreur'] as String?,
nombreTentatives: (json['nombreTentatives'] as num?)?.toInt(),
dateEcheance: json['dateEcheance'] == null
? null
: DateTime.parse(json['dateEcheance'] as String),
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
);
Map<String, dynamic> _$PaymentModelToJson(PaymentModel instance) =>
<String, dynamic>{
'id': instance.id,
'cotisationId': instance.cotisationId,
'numeroReference': instance.numeroReference,
'montant': instance.montant,
'codeDevise': instance.codeDevise,
'methodePaiement': instance.methodePaiement,
'statut': instance.statut,
'dateTransaction': instance.dateTransaction.toIso8601String(),
'numeroTransaction': instance.numeroTransaction,
'referencePaiement': instance.referencePaiement,
'description': instance.description,
'metadonnees': instance.metadonnees,
'operateurMobileMoney': instance.operateurMobileMoney,
'numeroTelephone': instance.numeroTelephone,
'nomPayeur': instance.nomPayeur,
'emailPayeur': instance.emailPayeur,
'fraisTransaction': instance.fraisTransaction,
'codeAutorisation': instance.codeAutorisation,
'messageErreur': instance.messageErreur,
'nombreTentatives': instance.nombreTentatives,
'dateEcheance': instance.dateEcheance?.toIso8601String(),
'dateCreation': instance.dateCreation.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
};

View File

@@ -1,206 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'wave_checkout_session_model.g.dart';
/// Modèle pour les sessions de paiement Wave Money
/// Aligné avec WaveCheckoutSessionDTO du serveur API
@JsonSerializable()
class WaveCheckoutSessionModel extends Equatable {
/// ID unique de la session
final String? id;
/// ID de la session Wave (retourné par l'API Wave)
@JsonKey(name: 'waveSessionId')
final String waveSessionId;
/// URL de la session de paiement Wave
@JsonKey(name: 'waveUrl')
final String? waveUrl;
/// Montant du paiement
final double montant;
/// Devise (XOF pour la Côte d'Ivoire)
final String devise;
/// URL de succès (redirection après paiement réussi)
@JsonKey(name: 'successUrl')
final String successUrl;
/// URL d'erreur (redirection après échec)
@JsonKey(name: 'errorUrl')
final String errorUrl;
/// Statut de la session
final String statut;
/// ID de l'organisation qui effectue le paiement
@JsonKey(name: 'organisationId')
final String? organisationId;
/// Nom de l'organisation
@JsonKey(name: 'nomOrganisation')
final String? nomOrganisation;
/// ID du membre qui effectue le paiement
@JsonKey(name: 'membreId')
final String? membreId;
/// Nom du membre
@JsonKey(name: 'nomMembre')
final String? nomMembre;
/// Type de paiement (COTISATION, ADHESION, AIDE, EVENEMENT)
@JsonKey(name: 'typePaiement')
final String? typePaiement;
/// Description du paiement
final String? description;
/// Référence externe
@JsonKey(name: 'referenceExterne')
final String? referenceExterne;
/// Date de création
@JsonKey(name: 'dateCreation')
final DateTime dateCreation;
/// Date d'expiration
@JsonKey(name: 'dateExpiration')
final DateTime? dateExpiration;
/// Date de dernière modification
@JsonKey(name: 'dateModification')
final DateTime? dateModification;
/// Indique si la session est active
final bool actif;
/// Version pour optimistic locking
final int version;
const WaveCheckoutSessionModel({
this.id,
required this.waveSessionId,
this.waveUrl,
required this.montant,
required this.devise,
required this.successUrl,
required this.errorUrl,
required this.statut,
this.organisationId,
this.nomOrganisation,
this.membreId,
this.nomMembre,
this.typePaiement,
this.description,
this.referenceExterne,
required this.dateCreation,
this.dateExpiration,
this.dateModification,
required this.actif,
required this.version,
});
/// Constructeur depuis JSON
factory WaveCheckoutSessionModel.fromJson(Map<String, dynamic> json) =>
_$WaveCheckoutSessionModelFromJson(json);
/// Conversion vers JSON
Map<String, dynamic> toJson() => _$WaveCheckoutSessionModelToJson(this);
/// Montant formaté avec devise
String get montantFormate => '${montant.toStringAsFixed(0)} $devise';
/// Indique si la session est expirée
bool get estExpiree {
if (dateExpiration == null) return false;
return DateTime.now().isAfter(dateExpiration!);
}
/// Indique si la session est en attente
bool get estEnAttente => statut == 'PENDING' || statut == 'EN_ATTENTE';
/// Indique si la session est réussie
bool get estReussie => statut == 'SUCCESS' || statut == 'REUSSIE';
/// Indique si la session a échoué
bool get aEchoue => statut == 'FAILED' || statut == 'ECHEC';
/// Copie avec modifications
WaveCheckoutSessionModel copyWith({
String? id,
String? waveSessionId,
String? waveUrl,
double? montant,
String? devise,
String? successUrl,
String? errorUrl,
String? statut,
String? organisationId,
String? nomOrganisation,
String? membreId,
String? nomMembre,
String? typePaiement,
String? description,
String? referenceExterne,
DateTime? dateCreation,
DateTime? dateExpiration,
DateTime? dateModification,
bool? actif,
int? version,
}) {
return WaveCheckoutSessionModel(
id: id ?? this.id,
waveSessionId: waveSessionId ?? this.waveSessionId,
waveUrl: waveUrl ?? this.waveUrl,
montant: montant ?? this.montant,
devise: devise ?? this.devise,
successUrl: successUrl ?? this.successUrl,
errorUrl: errorUrl ?? this.errorUrl,
statut: statut ?? this.statut,
organisationId: organisationId ?? this.organisationId,
nomOrganisation: nomOrganisation ?? this.nomOrganisation,
membreId: membreId ?? this.membreId,
nomMembre: nomMembre ?? this.nomMembre,
typePaiement: typePaiement ?? this.typePaiement,
description: description ?? this.description,
referenceExterne: referenceExterne ?? this.referenceExterne,
dateCreation: dateCreation ?? this.dateCreation,
dateExpiration: dateExpiration ?? this.dateExpiration,
dateModification: dateModification ?? this.dateModification,
actif: actif ?? this.actif,
version: version ?? this.version,
);
}
@override
List<Object?> get props => [
id,
waveSessionId,
waveUrl,
montant,
devise,
successUrl,
errorUrl,
statut,
organisationId,
nomOrganisation,
membreId,
nomMembre,
typePaiement,
description,
referenceExterne,
dateCreation,
dateExpiration,
dateModification,
actif,
version,
];
@override
String toString() => 'WaveCheckoutSessionModel(id: $id, '
'waveSessionId: $waveSessionId, montant: $montantFormate, '
'statut: $statut, typePaiement: $typePaiement)';
}

View File

@@ -1,61 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'wave_checkout_session_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
WaveCheckoutSessionModel _$WaveCheckoutSessionModelFromJson(
Map<String, dynamic> json) =>
WaveCheckoutSessionModel(
id: json['id'] as String?,
waveSessionId: json['waveSessionId'] as String,
waveUrl: json['waveUrl'] as String?,
montant: (json['montant'] as num).toDouble(),
devise: json['devise'] as String,
successUrl: json['successUrl'] as String,
errorUrl: json['errorUrl'] as String,
statut: json['statut'] as String,
organisationId: json['organisationId'] as String?,
nomOrganisation: json['nomOrganisation'] as String?,
membreId: json['membreId'] as String?,
nomMembre: json['nomMembre'] as String?,
typePaiement: json['typePaiement'] as String?,
description: json['description'] as String?,
referenceExterne: json['referenceExterne'] as String?,
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateExpiration: json['dateExpiration'] == null
? null
: DateTime.parse(json['dateExpiration'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
actif: json['actif'] as bool,
version: (json['version'] as num).toInt(),
);
Map<String, dynamic> _$WaveCheckoutSessionModelToJson(
WaveCheckoutSessionModel instance) =>
<String, dynamic>{
'id': instance.id,
'waveSessionId': instance.waveSessionId,
'waveUrl': instance.waveUrl,
'montant': instance.montant,
'devise': instance.devise,
'successUrl': instance.successUrl,
'errorUrl': instance.errorUrl,
'statut': instance.statut,
'organisationId': instance.organisationId,
'nomOrganisation': instance.nomOrganisation,
'membreId': instance.membreId,
'nomMembre': instance.nomMembre,
'typePaiement': instance.typePaiement,
'description': instance.description,
'referenceExterne': instance.referenceExterne,
'dateCreation': instance.dateCreation.toIso8601String(),
'dateExpiration': instance.dateExpiration?.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
'actif': instance.actif,
'version': instance.version,
};

View File

@@ -0,0 +1,561 @@
/// Système de navigation adaptatif basé sur les rôles
/// Navigation qui s'adapte selon les permissions et rôles utilisateurs
library adaptive_navigation;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../auth/bloc/auth_bloc.dart';
import '../auth/models/user_role.dart';
import '../auth/models/permission_matrix.dart';
import '../widgets/adaptive_widget.dart';
/// Élément de navigation adaptatif
class AdaptiveNavigationItem {
/// Icône de l'élément
final IconData icon;
/// Icône sélectionnée (optionnelle)
final IconData? selectedIcon;
/// Libellé de l'élément
final String label;
/// Route de destination
final String route;
/// Permissions requises pour afficher cet élément
final List<String> requiredPermissions;
/// Rôles minimum requis
final UserRole? minimumRole;
/// Badge de notification (optionnel)
final String? badge;
/// Couleur personnalisée (optionnelle)
final Color? color;
const AdaptiveNavigationItem({
required this.icon,
this.selectedIcon,
required this.label,
required this.route,
this.requiredPermissions = const [],
this.minimumRole,
this.badge,
this.color,
});
}
/// Drawer de navigation adaptatif
class AdaptiveNavigationDrawer extends StatelessWidget {
/// Callback de navigation
final Function(String route) onNavigate;
/// Callback de déconnexion
final VoidCallback onLogout;
/// Éléments de navigation personnalisés
final List<AdaptiveNavigationItem>? customItems;
const AdaptiveNavigationDrawer({
super.key,
required this.onNavigate,
required this.onLogout,
this.customItems,
});
@override
Widget build(BuildContext context) {
return AdaptiveWidget(
roleWidgets: {
UserRole.superAdmin: () => _buildSuperAdminDrawer(context),
UserRole.orgAdmin: () => _buildOrgAdminDrawer(context),
UserRole.moderator: () => _buildModeratorDrawer(context),
UserRole.activeMember: () => _buildActiveMemberDrawer(context),
UserRole.simpleMember: () => _buildSimpleMemberDrawer(context),
UserRole.visitor: () => _buildVisitorDrawer(context),
},
fallbackWidget: _buildBasicDrawer(context),
loadingWidget: _buildLoadingDrawer(context),
);
}
/// Drawer pour Super Admin
Widget _buildSuperAdminDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Command Center',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.SYSTEM_ADMIN],
),
const AdaptiveNavigationItem(
icon: Icons.business,
label: 'Organisations',
route: '/organizations',
requiredPermissions: [PermissionMatrix.ORG_CREATE],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Utilisateurs Globaux',
route: '/global-users',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.settings,
label: 'Administration',
route: '/system-admin',
requiredPermissions: [PermissionMatrix.SYSTEM_CONFIG],
),
const AdaptiveNavigationItem(
icon: Icons.analytics,
label: 'Analytics',
route: '/analytics',
requiredPermissions: [PermissionMatrix.DASHBOARD_ANALYTICS],
),
const AdaptiveNavigationItem(
icon: Icons.security,
label: 'Sécurité',
route: '/security',
requiredPermissions: [PermissionMatrix.SYSTEM_SECURITY],
),
];
return _buildDrawer(
context,
'Super Administrateur',
const Color(0xFF6C5CE7),
Icons.admin_panel_settings,
items,
);
}
/// Drawer pour Org Admin
Widget _buildOrgAdminDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Control Panel',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Membres',
route: '/members',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.account_balance_wallet,
label: 'Finances',
route: '/finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.volunteer_activism,
label: 'Solidarité',
route: '/solidarity',
requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.assessment,
label: 'Rapports',
route: '/reports',
requiredPermissions: [PermissionMatrix.REPORTS_GENERATE],
),
const AdaptiveNavigationItem(
icon: Icons.settings,
label: 'Configuration',
route: '/org-settings',
requiredPermissions: [PermissionMatrix.ORG_CONFIG],
),
];
return _buildDrawer(
context,
'Administrateur',
const Color(0xFF0984E3),
Icons.business_center,
items,
);
}
/// Drawer pour Modérateur
Widget _buildModeratorDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Management Hub',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.gavel,
label: 'Modération',
route: '/moderation',
requiredPermissions: [PermissionMatrix.MODERATION_CONTENT],
),
const AdaptiveNavigationItem(
icon: Icons.people,
label: 'Membres',
route: '/members',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.message,
label: 'Communication',
route: '/communication',
requiredPermissions: [PermissionMatrix.COMM_MODERATE],
),
];
return _buildDrawer(
context,
'Modérateur',
const Color(0xFFE17055),
Icons.manage_accounts,
items,
);
}
/// Drawer pour Membre Actif
Widget _buildActiveMemberDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Activity Center',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.person,
label: 'Mon Profil',
route: '/profile',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.volunteer_activism,
label: 'Solidarité',
route: '/solidarity',
requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL],
),
const AdaptiveNavigationItem(
icon: Icons.payment,
label: 'Mes Cotisations',
route: '/my-finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.message,
label: 'Messages',
route: '/messages',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
];
return _buildDrawer(
context,
'Membre Actif',
const Color(0xFF00B894),
Icons.groups,
items,
);
}
/// Drawer pour Membre Simple
Widget _buildSimpleMemberDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.dashboard,
label: 'Mon Espace',
route: '/dashboard',
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
),
const AdaptiveNavigationItem(
icon: Icons.person,
label: 'Mon Profil',
route: '/profile',
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements',
route: '/events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC],
),
const AdaptiveNavigationItem(
icon: Icons.payment,
label: 'Mes Cotisations',
route: '/my-finances',
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN],
),
const AdaptiveNavigationItem(
icon: Icons.help,
label: 'Aide',
route: '/help',
requiredPermissions: [],
),
];
return _buildDrawer(
context,
'Membre',
const Color(0xFF00CEC9),
Icons.person,
items,
);
}
/// Drawer pour Visiteur
Widget _buildVisitorDrawer(BuildContext context) {
final items = [
const AdaptiveNavigationItem(
icon: Icons.home,
label: 'Accueil',
route: '/dashboard',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.info,
label: 'À Propos',
route: '/about',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.event,
label: 'Événements Publics',
route: '/public-events',
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC],
),
const AdaptiveNavigationItem(
icon: Icons.contact_mail,
label: 'Contact',
route: '/contact',
requiredPermissions: [],
),
const AdaptiveNavigationItem(
icon: Icons.login,
label: 'Se Connecter',
route: '/login',
requiredPermissions: [],
),
];
return _buildDrawer(
context,
'Visiteur',
const Color(0xFF6C5CE7),
Icons.waving_hand,
items,
);
}
/// Drawer basique de fallback
Widget _buildBasicDrawer(BuildContext context) {
return _buildDrawer(
context,
'UnionFlow',
Colors.grey,
Icons.dashboard,
[
const AdaptiveNavigationItem(
icon: Icons.home,
label: 'Accueil',
route: '/dashboard',
),
],
);
}
/// Drawer de chargement
Widget _buildLoadingDrawer(BuildContext context) {
return Drawer(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
),
),
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
);
}
/// Construit un drawer avec les éléments spécifiés
Widget _buildDrawer(
BuildContext context,
String title,
Color color,
IconData icon,
List<AdaptiveNavigationItem> items,
) {
return Drawer(
child: Column(
children: [
// En-tête du drawer
_buildDrawerHeader(context, title, color, icon),
// Éléments de navigation
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
...items.map((item) => _buildNavigationItem(context, item)),
const Divider(),
_buildLogoutItem(context),
],
),
),
],
),
);
}
/// Construit l'en-tête du drawer
Widget _buildDrawerHeader(
BuildContext context,
String title,
Color color,
IconData icon,
) {
return DrawerHeader(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color, color.withOpacity(0.8)],
),
),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthAuthenticated) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Colors.white, size: 32),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
Text(
state.user.fullName,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
Text(
state.user.email,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
),
),
],
);
}
return Row(
children: [
Icon(icon, color: Colors.white, size: 32),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
);
},
),
);
}
/// Construit un élément de navigation
Widget _buildNavigationItem(
BuildContext context,
AdaptiveNavigationItem item,
) {
return SecureWidget(
requiredPermissions: item.requiredPermissions,
child: ListTile(
leading: Icon(item.icon, color: item.color),
title: Text(item.label),
trailing: item.badge != null
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
item.badge!,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
)
: null,
onTap: () {
Navigator.of(context).pop();
onNavigate(item.route);
},
),
);
}
/// Construit l'élément de déconnexion
Widget _buildLogoutItem(BuildContext context) {
return ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text(
'Déconnexion',
style: TextStyle(color: Colors.red),
),
onTap: () {
Navigator.of(context).pop();
onLogout();
},
);
}
}

View File

@@ -1,115 +0,0 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Interceptor pour gérer l'authentification automatique
@singleton
class AuthInterceptor extends Interceptor {
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
// Callback pour déclencher le refresh token
void Function()? onTokenRefreshNeeded;
// Callback pour déconnecter l'utilisateur
void Function()? onAuthenticationFailed;
AuthInterceptor();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
// Ignorer l'authentification pour certaines routes
if (_shouldSkipAuth(options)) {
handler.next(options);
return;
}
try {
// Récupérer le token d'accès
final accessToken = await _secureStorage.read(key: 'access_token');
if (accessToken != null) {
// Ajouter le token à l'en-tête Authorization
options.headers['Authorization'] = 'Bearer $accessToken';
}
handler.next(options);
} catch (e) {
// En cas d'erreur, continuer sans token
print('Erreur lors de la récupération du token: $e');
handler.next(options);
}
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// Traitement des réponses réussies
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Gestion des erreurs d'authentification
if (err.response?.statusCode == 401) {
await _handle401Error(err, handler);
} else if (err.response?.statusCode == 403) {
await _handle403Error(err, handler);
} else {
handler.next(err);
}
}
/// Gère les erreurs 401 (Non autorisé)
Future<void> _handle401Error(DioException err, ErrorInterceptorHandler handler) async {
try {
// Déclencher la déconnexion automatique
onAuthenticationFailed?.call();
// Nettoyer les tokens
await _secureStorage.deleteAll();
} catch (e) {
print('Erreur lors de la gestion de l\'erreur 401: $e');
}
handler.next(err);
}
/// Gère les erreurs 403 (Interdit)
Future<void> _handle403Error(DioException err, ErrorInterceptorHandler handler) async {
// L'utilisateur n'a pas les permissions suffisantes
// On peut logger cela ou rediriger vers une page d'erreur
print('Accès interdit (403) pour: ${err.requestOptions.path}');
handler.next(err);
}
/// Détermine si l'authentification doit être ignorée pour une requête
bool _shouldSkipAuth(RequestOptions options) {
// Ignorer l'auth pour les routes publiques
final publicPaths = [
'/api/auth/login',
'/api/auth/refresh',
'/api/auth/info',
'/api/auth/register',
'/api/health',
];
// Vérifier si le path est dans la liste des routes publiques
final isPublicPath = publicPaths.any((path) => options.path.contains(path));
// Vérifier si l'option skipAuth est activée
final skipAuth = options.extra['skipAuth'] == true;
return isPublicPath || skipAuth;
}
/// Configuration des callbacks
void setCallbacks({
void Function()? onTokenRefreshNeeded,
void Function()? onAuthenticationFailed,
}) {
this.onTokenRefreshNeeded = onTokenRefreshNeeded;
this.onAuthenticationFailed = onAuthenticationFailed;
}
}

View File

@@ -1,113 +0,0 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import 'auth_interceptor.dart';
/// Configuration centralisée du client HTTP Dio
@singleton
class DioClient {
late final Dio _dio;
DioClient() {
_dio = Dio();
_setupInterceptors();
_configureOptions();
}
Dio get dio => _dio;
void _configureOptions() {
_dio.options = BaseOptions(
// URL de base de l'API
baseUrl: 'http://192.168.1.11:8080', // Adresse de votre API Quarkus
// Timeouts
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
// Headers par défaut
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'UnionFlow-Mobile/1.0.0',
},
// Validation des codes de statut
validateStatus: (status) {
return status != null && status < 500;
},
// Suivre les redirections
followRedirects: true,
maxRedirects: 3,
// Politique de persistance des cookies
persistentConnection: true,
// Format de réponse par défaut
responseType: ResponseType.json,
);
}
void _setupInterceptors() {
// Interceptor de logging (seulement en debug)
_dio.interceptors.add(
PrettyDioLogger(
requestHeader: true,
requestBody: true,
responseBody: true,
responseHeader: false,
error: true,
compact: true,
maxWidth: 90,
filter: (options, args) {
// Ne pas logger les mots de passe
if (options.path.contains('/auth/login')) {
return false;
}
return true;
},
),
);
// Interceptor d'authentification (sera injecté plus tard)
// Il sera ajouté dans AuthService pour éviter les dépendances circulaires
}
/// Ajoute l'interceptor d'authentification
void addAuthInterceptor(AuthInterceptor authInterceptor) {
_dio.interceptors.add(authInterceptor);
}
/// Configure l'URL de base
void setBaseUrl(String baseUrl) {
_dio.options.baseUrl = baseUrl;
}
/// Ajoute un header global
void addHeader(String key, String value) {
_dio.options.headers[key] = value;
}
/// Supprime un header global
void removeHeader(String key) {
_dio.options.headers.remove(key);
}
/// Configure les timeouts
void setTimeout({
Duration? connect,
Duration? receive,
Duration? send,
}) {
if (connect != null) _dio.options.connectTimeout = connect;
if (receive != null) _dio.options.receiveTimeout = receive;
if (send != null) _dio.options.sendTimeout = send;
}
/// Nettoie et ferme le client
void dispose() {
_dio.close();
}
}

View File

@@ -1,338 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// Service d'optimisation des performances pour l'application UnionFlow
///
/// Fournit des utilitaires pour :
/// - Optimisation des widgets
/// - Gestion de la mémoire
/// - Mise en cache intelligente
/// - Monitoring des performances
class PerformanceOptimizer {
static const String _tag = 'PerformanceOptimizer';
/// Singleton instance
static final PerformanceOptimizer _instance = PerformanceOptimizer._internal();
factory PerformanceOptimizer() => _instance;
PerformanceOptimizer._internal();
/// Cache pour les widgets optimisés
final Map<String, Widget> _widgetCache = {};
/// Cache pour les images
final Map<String, ImageProvider> _imageCache = {};
/// Compteurs de performance
final Map<String, int> _performanceCounters = {};
/// Temps de début pour les mesures
final Map<String, DateTime> _performanceTimers = {};
// ========================================
// OPTIMISATION DES WIDGETS
// ========================================
/// Optimise un widget avec RepaintBoundary si nécessaire
static Widget optimizeWidget(Widget child, {
String? key,
bool forceRepaintBoundary = false,
bool addSemantics = true,
}) {
Widget optimized = child;
// Ajouter RepaintBoundary pour les widgets complexes
if (forceRepaintBoundary || _shouldAddRepaintBoundary(child)) {
optimized = RepaintBoundary(
key: key != null ? Key('repaint_$key') : null,
child: optimized,
);
}
// Ajouter Semantics pour l'accessibilité
if (addSemantics && _shouldAddSemantics(child)) {
optimized = Semantics(
key: key != null ? Key('semantics_$key') : null,
child: optimized,
);
}
return optimized;
}
/// Détermine si un RepaintBoundary est nécessaire
static bool _shouldAddRepaintBoundary(Widget widget) {
// Ajouter RepaintBoundary pour les widgets qui changent fréquemment
return widget is AnimatedWidget ||
widget is CustomPaint ||
widget is Image ||
widget.runtimeType.toString().contains('Chart') ||
widget.runtimeType.toString().contains('Graph');
}
/// Détermine si Semantics est nécessaire
static bool _shouldAddSemantics(Widget widget) {
return widget is GestureDetector ||
widget is InkWell ||
widget is ElevatedButton ||
widget is TextButton ||
widget is IconButton;
}
/// Crée un widget avec mise en cache
Widget cachedWidget(String key, Widget Function() builder) {
if (_widgetCache.containsKey(key)) {
return _widgetCache[key]!;
}
final widget = builder();
_widgetCache[key] = widget;
return widget;
}
/// Nettoie le cache des widgets
void clearWidgetCache() {
_widgetCache.clear();
debugPrint('$_tag: Widget cache cleared');
}
// ========================================
// OPTIMISATION DES IMAGES
// ========================================
/// Optimise le chargement d'une image
static ImageProvider optimizeImage(String path, {
double? width,
double? height,
BoxFit fit = BoxFit.cover,
}) {
// Utiliser ResizeImage pour optimiser la mémoire
if (width != null || height != null) {
return ResizeImage(
AssetImage(path),
width: width?.round(),
height: height?.round(),
);
}
return AssetImage(path);
}
/// Met en cache une image
ImageProvider cachedImage(String key, String path) {
if (_imageCache.containsKey(key)) {
return _imageCache[key]!;
}
final image = AssetImage(path);
_imageCache[key] = image;
return image;
}
/// Précharge les images critiques
static Future<void> preloadCriticalImages(BuildContext context, List<String> imagePaths) async {
final futures = imagePaths.map((path) =>
precacheImage(AssetImage(path), context)
).toList();
await Future.wait(futures);
debugPrint('$_tag: ${imagePaths.length} critical images preloaded');
}
// ========================================
// MONITORING DES PERFORMANCES
// ========================================
/// Démarre un timer de performance
void startTimer(String operation) {
_performanceTimers[operation] = DateTime.now();
}
/// Arrête un timer et log le résultat
void stopTimer(String operation) {
final startTime = _performanceTimers[operation];
if (startTime != null) {
final duration = DateTime.now().difference(startTime);
debugPrint('$_tag: $operation took ${duration.inMilliseconds}ms');
_performanceTimers.remove(operation);
// Incrémenter le compteur
_performanceCounters[operation] = (_performanceCounters[operation] ?? 0) + 1;
}
}
/// Incrémente un compteur de performance
void incrementCounter(String metric) {
_performanceCounters[metric] = (_performanceCounters[metric] ?? 0) + 1;
}
/// Obtient les statistiques de performance
Map<String, int> getPerformanceStats() {
return Map.from(_performanceCounters);
}
/// Réinitialise les statistiques
void resetStats() {
_performanceCounters.clear();
_performanceTimers.clear();
debugPrint('$_tag: Performance stats reset');
}
// ========================================
// OPTIMISATION MÉMOIRE
// ========================================
/// Force le garbage collection (debug uniquement)
static void forceGarbageCollection() {
if (kDebugMode) {
// Forcer le GC en créant et supprimant des objets
final temp = List.generate(1000, (i) => Object());
temp.clear();
debugPrint('PerformanceOptimizer: Forced garbage collection');
}
}
/// Nettoie tous les caches
void clearAllCaches() {
clearWidgetCache();
_imageCache.clear();
debugPrint('$_tag: All caches cleared');
}
/// Obtient la taille des caches
Map<String, int> getCacheSizes() {
return {
'widgets': _widgetCache.length,
'images': _imageCache.length,
};
}
// ========================================
// OPTIMISATION DES ANIMATIONS
// ========================================
/// Crée un AnimationController optimisé
static AnimationController createOptimizedController({
required Duration duration,
required TickerProvider vsync,
double? value,
Duration? reverseDuration,
String? debugLabel,
}) {
return AnimationController(
duration: duration,
reverseDuration: reverseDuration,
vsync: vsync,
value: value,
debugLabel: debugLabel ?? 'OptimizedController',
);
}
/// Dispose proprement une liste d'AnimationControllers
static void disposeControllers(List<AnimationController> controllers) {
for (final controller in controllers) {
try {
controller.dispose();
} catch (e) {
// Controller déjà disposé, ignorer l'erreur
debugPrint('$_tag: Controller already disposed: $e');
}
}
controllers.clear();
}
// ========================================
// UTILITAIRES DE PERFORMANCE
// ========================================
/// Vérifie si l'appareil est performant
static bool isHighPerformanceDevice() {
// Logique basée sur les capacités de l'appareil
// Pour l'instant, retourne true par défaut
return true;
}
/// Obtient le niveau de performance recommandé
static PerformanceLevel getRecommendedPerformanceLevel() {
if (isHighPerformanceDevice()) {
return PerformanceLevel.high;
} else {
return PerformanceLevel.medium;
}
}
/// Applique les optimisations selon le niveau de performance
static void applyPerformanceLevel(PerformanceLevel level) {
switch (level) {
case PerformanceLevel.high:
// Toutes les animations et effets activés
debugPrint('$_tag: High performance mode enabled');
break;
case PerformanceLevel.medium:
// Animations réduites
debugPrint('$_tag: Medium performance mode enabled');
break;
case PerformanceLevel.low:
// Animations désactivées
debugPrint('$_tag: Low performance mode enabled');
break;
}
}
// ========================================
// MONITORING EN TEMPS RÉEL
// ========================================
/// Démarre le monitoring des performances
void startPerformanceMonitoring() {
// Monitoring du frame rate
WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
_monitorFrameRate();
});
// Monitoring de la mémoire (toutes les 30 secondes)
Timer.periodic(const Duration(seconds: 30), (_) {
_monitorMemoryUsage();
});
debugPrint('$_tag: Performance monitoring started');
}
void _monitorFrameRate() {
// Logique de monitoring du frame rate
// Pour l'instant, juste incrémenter un compteur
incrementCounter('frames_rendered');
}
void _monitorMemoryUsage() {
// Logique de monitoring de la mémoire
if (kDebugMode) {
final cacheSize = getCacheSizes();
debugPrint('$_tag: Cache sizes - Widgets: ${cacheSize['widgets']}, Images: ${cacheSize['images']}');
}
}
}
/// Niveaux de performance
enum PerformanceLevel {
low,
medium,
high,
}
/// Extension pour optimiser les widgets
extension WidgetOptimization on Widget {
/// Optimise ce widget
Widget optimized({
String? key,
bool forceRepaintBoundary = false,
bool addSemantics = true,
}) {
return PerformanceOptimizer.optimizeWidget(
this,
key: key,
forceRepaintBoundary: forceRepaintBoundary,
addSemantics: addSemantics,
);
}
}

View File

@@ -1,356 +0,0 @@
import 'dart:convert';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:injectable/injectable.dart';
/// Service de mise en cache intelligent pour optimiser les performances
///
/// Fonctionnalités :
/// - Cache multi-niveaux (mémoire + stockage)
/// - Expiration automatique des données
/// - Invalidation intelligente
/// - Compression des données
/// - Statistiques de cache
@singleton
class SmartCacheService {
static const String _tag = 'SmartCacheService';
/// Cache en mémoire (niveau 1)
final Map<String, CacheEntry> _memoryCache = {};
/// Instance SharedPreferences pour le cache persistant
SharedPreferences? _prefs;
/// Statistiques du cache
final CacheStats _stats = CacheStats();
/// Taille maximale du cache mémoire (nombre d'entrées)
static const int _maxMemoryCacheSize = 100;
/// Durée par défaut de validité du cache
static const Duration _defaultCacheDuration = Duration(minutes: 15);
/// Initialise le service de cache
Future<void> initialize() async {
_prefs = await SharedPreferences.getInstance();
await _cleanExpiredEntries();
debugPrint('$_tag: Service initialized');
}
// ========================================
// OPÉRATIONS DE CACHE PRINCIPALES
// ========================================
/// Met en cache une valeur avec une clé
Future<void> put<T>(
String key,
T value, {
Duration? duration,
CacheLevel level = CacheLevel.both,
bool compress = false,
}) async {
final entry = CacheEntry(
key: key,
value: value,
timestamp: DateTime.now(),
duration: duration ?? _defaultCacheDuration,
compressed: compress,
);
// Cache mémoire
if (level == CacheLevel.memory || level == CacheLevel.both) {
_putInMemory(key, entry);
}
// Cache persistant
if (level == CacheLevel.storage || level == CacheLevel.both) {
await _putInStorage(key, entry);
}
_stats.incrementWrites();
debugPrint('$_tag: Cached $key (level: $level)');
}
/// Récupère une valeur du cache
Future<T?> get<T>(String key, {CacheLevel level = CacheLevel.both}) async {
CacheEntry? entry;
// Essayer d'abord le cache mémoire (plus rapide)
if (level == CacheLevel.memory || level == CacheLevel.both) {
entry = _getFromMemory(key);
if (entry != null && !entry.isExpired) {
_stats.incrementHits();
debugPrint('$_tag: Memory cache hit for $key');
return entry.value as T?;
}
}
// Essayer le cache persistant
if (level == CacheLevel.storage || level == CacheLevel.both) {
entry = await _getFromStorage(key);
if (entry != null && !entry.isExpired) {
// Remettre en cache mémoire pour les prochains accès
_putInMemory(key, entry);
_stats.incrementHits();
debugPrint('$_tag: Storage cache hit for $key');
return entry.value as T?;
}
}
_stats.incrementMisses();
debugPrint('$_tag: Cache miss for $key');
return null;
}
/// Vérifie si une clé existe dans le cache
Future<bool> contains(String key, {CacheLevel level = CacheLevel.both}) async {
if (level == CacheLevel.memory || level == CacheLevel.both) {
final entry = _getFromMemory(key);
if (entry != null && !entry.isExpired) return true;
}
if (level == CacheLevel.storage || level == CacheLevel.both) {
final entry = await _getFromStorage(key);
if (entry != null && !entry.isExpired) return true;
}
return false;
}
/// Supprime une entrée du cache
Future<void> remove(String key, {CacheLevel level = CacheLevel.both}) async {
if (level == CacheLevel.memory || level == CacheLevel.both) {
_memoryCache.remove(key);
}
if (level == CacheLevel.storage || level == CacheLevel.both) {
await _prefs?.remove(_getStorageKey(key));
}
debugPrint('$_tag: Removed $key from cache');
}
/// Vide complètement le cache
Future<void> clear({CacheLevel level = CacheLevel.both}) async {
if (level == CacheLevel.memory || level == CacheLevel.both) {
_memoryCache.clear();
}
if (level == CacheLevel.storage || level == CacheLevel.both) {
final keys = _prefs?.getKeys().where((k) => k.startsWith('cache_')).toList() ?? [];
for (final key in keys) {
await _prefs?.remove(key);
}
}
_stats.reset();
debugPrint('$_tag: Cache cleared (level: $level)');
}
// ========================================
// CACHE MÉMOIRE
// ========================================
void _putInMemory(String key, CacheEntry entry) {
// Vérifier la taille du cache et nettoyer si nécessaire
if (_memoryCache.length >= _maxMemoryCacheSize) {
_evictOldestMemoryEntry();
}
_memoryCache[key] = entry;
}
CacheEntry? _getFromMemory(String key) {
return _memoryCache[key];
}
void _evictOldestMemoryEntry() {
if (_memoryCache.isEmpty) return;
String? oldestKey;
DateTime? oldestTime;
for (final entry in _memoryCache.entries) {
if (oldestTime == null || entry.value.timestamp.isBefore(oldestTime)) {
oldestTime = entry.value.timestamp;
oldestKey = entry.key;
}
}
if (oldestKey != null) {
_memoryCache.remove(oldestKey);
debugPrint('$_tag: Evicted oldest memory entry: $oldestKey');
}
}
// ========================================
// CACHE PERSISTANT
// ========================================
Future<void> _putInStorage(String key, CacheEntry entry) async {
final storageKey = _getStorageKey(key);
final jsonData = entry.toJson();
await _prefs?.setString(storageKey, jsonEncode(jsonData));
}
Future<CacheEntry?> _getFromStorage(String key) async {
final storageKey = _getStorageKey(key);
final jsonString = _prefs?.getString(storageKey);
if (jsonString == null) return null;
try {
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
return CacheEntry.fromJson(jsonData);
} catch (e) {
debugPrint('$_tag: Error deserializing cache entry $key: $e');
await _prefs?.remove(storageKey);
return null;
}
}
String _getStorageKey(String key) => 'cache_$key';
// ========================================
// NETTOYAGE ET MAINTENANCE
// ========================================
/// Nettoie les entrées expirées
Future<void> _cleanExpiredEntries() async {
// Nettoyer le cache mémoire
final expiredMemoryKeys = _memoryCache.entries
.where((entry) => entry.value.isExpired)
.map((entry) => entry.key)
.toList();
for (final key in expiredMemoryKeys) {
_memoryCache.remove(key);
}
// Nettoyer le cache persistant
final allKeys = _prefs?.getKeys().where((k) => k.startsWith('cache_')).toList() ?? [];
int cleanedCount = 0;
for (final storageKey in allKeys) {
final key = storageKey.substring(6); // Enlever 'cache_'
final entry = await _getFromStorage(key);
if (entry?.isExpired == true) {
await _prefs?.remove(storageKey);
cleanedCount++;
}
}
debugPrint('$_tag: Cleaned ${expiredMemoryKeys.length} memory entries and $cleanedCount storage entries');
}
/// Nettoie périodiquement le cache
void startPeriodicCleanup() {
Timer.periodic(const Duration(minutes: 30), (_) {
_cleanExpiredEntries();
});
}
// ========================================
// STATISTIQUES
// ========================================
/// Obtient les statistiques du cache
CacheStats getStats() => _stats;
/// Obtient des informations détaillées sur le cache
Future<CacheInfo> getCacheInfo() async {
final memorySize = _memoryCache.length;
final storageKeys = _prefs?.getKeys().where((k) => k.startsWith('cache_')).length ?? 0;
return CacheInfo(
memoryEntries: memorySize,
storageEntries: storageKeys,
stats: _stats,
);
}
}
/// Niveaux de cache
enum CacheLevel {
memory, // Cache en mémoire uniquement
storage, // Cache persistant uniquement
both, // Les deux niveaux
}
/// Entrée de cache
class CacheEntry {
final String key;
final dynamic value;
final DateTime timestamp;
final Duration duration;
final bool compressed;
CacheEntry({
required this.key,
required this.value,
required this.timestamp,
required this.duration,
this.compressed = false,
});
bool get isExpired => DateTime.now().difference(timestamp) > duration;
Map<String, dynamic> toJson() => {
'key': key,
'value': value,
'timestamp': timestamp.millisecondsSinceEpoch,
'duration': duration.inMilliseconds,
'compressed': compressed,
};
factory CacheEntry.fromJson(Map<String, dynamic> json) => CacheEntry(
key: json['key'],
value: json['value'],
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp']),
duration: Duration(milliseconds: json['duration']),
compressed: json['compressed'] ?? false,
);
}
/// Statistiques du cache
class CacheStats {
int _hits = 0;
int _misses = 0;
int _writes = 0;
int get hits => _hits;
int get misses => _misses;
int get writes => _writes;
double get hitRate => (_hits + _misses) > 0 ? _hits / (_hits + _misses) : 0.0;
void incrementHits() => _hits++;
void incrementMisses() => _misses++;
void incrementWrites() => _writes++;
void reset() {
_hits = 0;
_misses = 0;
_writes = 0;
}
@override
String toString() => 'CacheStats(hits: $_hits, misses: $_misses, writes: $_writes, hitRate: ${(hitRate * 100).toStringAsFixed(1)}%)';
}
/// Informations sur le cache
class CacheInfo {
final int memoryEntries;
final int storageEntries;
final CacheStats stats;
CacheInfo({
required this.memoryEntries,
required this.storageEntries,
required this.stats,
});
@override
String toString() => 'CacheInfo(memory: $memoryEntries, storage: $storageEntries, $stats)';
}

View File

@@ -1,715 +0,0 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../models/membre_model.dart';
import '../models/cotisation_model.dart';
import '../models/evenement_model.dart';
import '../models/wave_checkout_session_model.dart';
import '../models/payment_model.dart';
import '../network/dio_client.dart';
/// Service API principal pour communiquer avec le serveur UnionFlow
@singleton
class ApiService {
final DioClient _dioClient;
ApiService(this._dioClient);
Dio get _dio => _dioClient.dio;
// ========================================
// MEMBRES
// ========================================
/// Récupère la liste de tous les membres actifs
Future<List<MembreModel>> getMembres() async {
try {
final response = await _dio.get('/api/membres');
if (response.data is List) {
return (response.data as List)
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la liste des membres');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des membres');
}
}
/// Récupère un membre par son ID
Future<MembreModel> getMembreById(String id) async {
try {
final response = await _dio.get('/api/membres/$id');
return MembreModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération du membre');
}
}
/// Crée un nouveau membre
Future<MembreModel> createMembre(MembreModel membre) async {
try {
final response = await _dio.post(
'/api/membres',
data: membre.toJson(),
);
return MembreModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la création du membre');
}
}
/// Met à jour un membre existant
Future<MembreModel> updateMembre(String id, MembreModel membre) async {
try {
final response = await _dio.put(
'/api/membres/$id',
data: membre.toJson(),
);
return MembreModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la mise à jour du membre');
}
}
/// Désactive un membre
Future<void> deleteMembre(String id) async {
try {
await _dio.delete('/api/membres/$id');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la suppression du membre');
}
}
/// Recherche des membres par nom ou prénom
Future<List<MembreModel>> searchMembres(String query) async {
try {
final response = await _dio.get(
'/api/membres/recherche',
queryParameters: {'q': query},
);
if (response.data is List) {
return (response.data as List)
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la recherche');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la recherche de membres');
}
}
/// Recherche avancée des membres avec filtres multiples
Future<List<MembreModel>> advancedSearchMembres(Map<String, dynamic> filters) async {
try {
// Nettoyer les filtres vides
final cleanFilters = <String, dynamic>{};
filters.forEach((key, value) {
if (value != null && value.toString().isNotEmpty) {
cleanFilters[key] = value;
}
});
final response = await _dio.get(
'/api/membres/recherche-avancee',
queryParameters: cleanFilters,
);
if (response.data is List) {
return (response.data as List)
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la recherche avancée');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la recherche avancée de membres');
}
}
/// Récupère les statistiques des membres
Future<Map<String, dynamic>> getMembresStats() async {
try {
final response = await _dio.get('/api/membres/stats');
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques');
}
}
// ========================================
// PAIEMENTS WAVE
// ========================================
/// Crée une session de paiement Wave
Future<WaveCheckoutSessionModel> createWaveSession({
required double montant,
required String devise,
required String successUrl,
required String errorUrl,
String? organisationId,
String? membreId,
String? typePaiement,
String? description,
}) async {
try {
final response = await _dio.post(
'/api/paiements/wave/sessions',
data: {
'montant': montant,
'devise': devise,
'successUrl': successUrl,
'errorUrl': errorUrl,
if (organisationId != null) 'organisationId': organisationId,
if (membreId != null) 'membreId': membreId,
if (typePaiement != null) 'typePaiement': typePaiement,
if (description != null) 'description': description,
},
);
return WaveCheckoutSessionModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la création de la session Wave');
}
}
/// Récupère une session de paiement Wave par son ID
Future<WaveCheckoutSessionModel> getWaveSession(String sessionId) async {
try {
final response = await _dio.get('/api/paiements/wave/sessions/$sessionId');
return WaveCheckoutSessionModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération de la session Wave');
}
}
/// Vérifie le statut d'une session de paiement Wave
Future<String> checkWaveSessionStatus(String sessionId) async {
try {
final response = await _dio.get('/api/paiements/wave/sessions/$sessionId/status');
return response.data['statut'] as String;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la vérification du statut Wave');
}
}
// ========================================
// COTISATIONS
// ========================================
/// Récupère la liste de toutes les cotisations avec pagination
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20}) async {
try {
final response = await _dio.get('/api/cotisations', queryParameters: {
'page': page,
'size': size,
});
if (response.data is List) {
return (response.data as List)
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la liste des cotisations');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des cotisations');
}
}
/// Récupère une cotisation par son ID
Future<CotisationModel> getCotisationById(String id) async {
try {
final response = await _dio.get('/api/cotisations/$id');
return CotisationModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération de la cotisation');
}
}
/// Récupère une cotisation par son numéro de référence
Future<CotisationModel> getCotisationByReference(String numeroReference) async {
try {
final response = await _dio.get('/api/cotisations/reference/$numeroReference');
return CotisationModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération de la cotisation');
}
}
/// Crée une nouvelle cotisation
Future<CotisationModel> createCotisation(CotisationModel cotisation) async {
try {
final response = await _dio.post(
'/api/cotisations',
data: cotisation.toJson(),
);
return CotisationModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la création de la cotisation');
}
}
/// Met à jour une cotisation existante
Future<CotisationModel> updateCotisation(String id, CotisationModel cotisation) async {
try {
final response = await _dio.put(
'/api/cotisations/$id',
data: cotisation.toJson(),
);
return CotisationModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la mise à jour de la cotisation');
}
}
/// Supprime une cotisation
Future<void> deleteCotisation(String id) async {
try {
await _dio.delete('/api/cotisations/$id');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la suppression de la cotisation');
}
}
/// Récupère les cotisations d'un membre
Future<List<CotisationModel>> getCotisationsByMembre(String membreId, {int page = 0, int size = 20}) async {
try {
final response = await _dio.get('/api/cotisations/membre/$membreId', queryParameters: {
'page': page,
'size': size,
});
if (response.data is List) {
return (response.data as List)
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les cotisations du membre');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des cotisations du membre');
}
}
/// Récupère les cotisations par statut
Future<List<CotisationModel>> getCotisationsByStatut(String statut, {int page = 0, int size = 20}) async {
try {
final response = await _dio.get('/api/cotisations/statut/$statut', queryParameters: {
'page': page,
'size': size,
});
if (response.data is List) {
return (response.data as List)
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les cotisations par statut');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des cotisations par statut');
}
}
/// Récupère les cotisations en retard
Future<List<CotisationModel>> getCotisationsEnRetard({int page = 0, int size = 20}) async {
try {
final response = await _dio.get('/api/cotisations/en-retard', queryParameters: {
'page': page,
'size': size,
});
if (response.data is List) {
return (response.data as List)
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les cotisations en retard');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des cotisations en retard');
}
}
/// Recherche avancée de cotisations
Future<List<CotisationModel>> rechercherCotisations({
String? membreId,
String? statut,
String? typeCotisation,
int? annee,
int? mois,
int page = 0,
int size = 20,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'size': size,
};
if (membreId != null) queryParams['membreId'] = membreId;
if (statut != null) queryParams['statut'] = statut;
if (typeCotisation != null) queryParams['typeCotisation'] = typeCotisation;
if (annee != null) queryParams['annee'] = annee;
if (mois != null) queryParams['mois'] = mois;
final response = await _dio.get('/api/cotisations/recherche', queryParameters: queryParams);
if (response.data is List) {
return (response.data as List)
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la recherche de cotisations');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la recherche de cotisations');
}
}
/// Récupère les statistiques des cotisations
Future<Map<String, dynamic>> getCotisationsStats() async {
try {
final response = await _dio.get('/api/cotisations/stats');
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques des cotisations');
}
}
// ========================================
// GESTION DES ERREURS
// ========================================
/// Gère les exceptions Dio et les convertit en messages d'erreur appropriés
Exception _handleDioException(DioException e, String defaultMessage) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return Exception('Délai d\'attente dépassé. Vérifiez votre connexion internet.');
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
final responseData = e.response?.data;
if (statusCode == 400) {
if (responseData is Map && responseData.containsKey('message')) {
return Exception(responseData['message']);
}
return Exception('Données invalides');
} else if (statusCode == 401) {
return Exception('Non autorisé. Veuillez vous reconnecter.');
} else if (statusCode == 403) {
return Exception('Accès interdit');
} else if (statusCode == 404) {
return Exception('Ressource non trouvée');
} else if (statusCode == 500) {
return Exception('Erreur serveur. Veuillez réessayer plus tard.');
}
return Exception('$defaultMessage (Code: $statusCode)');
case DioExceptionType.cancel:
return Exception('Requête annulée');
case DioExceptionType.connectionError:
return Exception('Erreur de connexion. Vérifiez votre connexion internet.');
case DioExceptionType.badCertificate:
return Exception('Certificat SSL invalide');
case DioExceptionType.unknown:
default:
return Exception(defaultMessage);
}
}
// ========================================
// ÉVÉNEMENTS
// ========================================
/// Récupère la liste des événements à venir (optimisé mobile)
Future<List<EvenementModel>> getEvenementsAVenir({
int page = 0,
int size = 10,
}) async {
try {
final response = await _dio.get(
'/api/evenements/a-venir-public',
queryParameters: {
'page': page,
'size': size,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les événements à venir');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des événements à venir');
}
}
/// Récupère la liste des événements publics (sans authentification)
Future<List<EvenementModel>> getEvenementsPublics({
int page = 0,
int size = 20,
}) async {
try {
final response = await _dio.get(
'/api/evenements/publics',
queryParameters: {
'page': page,
'size': size,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les événements publics');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des événements publics');
}
}
/// Récupère tous les événements avec pagination
Future<List<EvenementModel>> getEvenements({
int page = 0,
int size = 20,
String sortField = 'dateDebut',
String sortDirection = 'asc',
}) async {
try {
final response = await _dio.get(
'/api/evenements',
queryParameters: {
'page': page,
'size': size,
'sort': sortField,
'direction': sortDirection,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la liste des événements');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des événements');
}
}
/// Récupère un événement par son ID
Future<EvenementModel> getEvenementById(String id) async {
try {
final response = await _dio.get('/api/evenements/$id');
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération de l\'événement');
}
}
/// Recherche d'événements par terme
Future<List<EvenementModel>> rechercherEvenements(
String terme, {
int page = 0,
int size = 20,
}) async {
try {
final response = await _dio.get(
'/api/evenements/recherche',
queryParameters: {
'q': terme,
'page': page,
'size': size,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la recherche d\'événements');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la recherche d\'événements');
}
}
/// Récupère les événements par type
Future<List<EvenementModel>> getEvenementsByType(
TypeEvenement type, {
int page = 0,
int size = 20,
}) async {
try {
final response = await _dio.get(
'/api/evenements/type/${type.name.toUpperCase()}',
queryParameters: {
'page': page,
'size': size,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les événements par type');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des événements par type');
}
}
/// Crée un nouvel événement
Future<EvenementModel> createEvenement(EvenementModel evenement) async {
try {
final response = await _dio.post(
'/api/evenements',
data: evenement.toJson(),
);
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la création de l\'événement');
}
}
/// Met à jour un événement existant
Future<EvenementModel> updateEvenement(String id, EvenementModel evenement) async {
try {
final response = await _dio.put(
'/api/evenements/$id',
data: evenement.toJson(),
);
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la mise à jour de l\'événement');
}
}
/// Supprime un événement
Future<void> deleteEvenement(String id) async {
try {
await _dio.delete('/api/evenements/$id');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la suppression de l\'événement');
}
}
/// Change le statut d'un événement
Future<EvenementModel> changerStatutEvenement(
String id,
StatutEvenement nouveauStatut,
) async {
try {
final response = await _dio.patch(
'/api/evenements/$id/statut',
queryParameters: {
'statut': nouveauStatut.name.toUpperCase(),
},
);
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors du changement de statut');
}
}
/// Récupère les statistiques des événements
Future<Map<String, dynamic>> getStatistiquesEvenements() async {
try {
final response = await _dio.get('/api/evenements/statistiques');
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques');
}
}
// ========================================
// PAIEMENTS
// ========================================
/// Initie un paiement
Future<PaymentModel> initiatePayment(Map<String, dynamic> paymentData) async {
try {
final response = await _dio.post('/api/paiements/initier', data: paymentData);
return PaymentModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de l\'initiation du paiement');
}
}
/// Récupère le statut d'un paiement
Future<PaymentModel> getPaymentStatus(String paymentId) async {
try {
final response = await _dio.get('/api/paiements/$paymentId/statut');
return PaymentModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la vérification du statut');
}
}
/// Annule un paiement
Future<bool> cancelPayment(String paymentId) async {
try {
final response = await _dio.post('/api/paiements/$paymentId/annuler');
return response.statusCode == 200;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de l\'annulation du paiement');
}
}
/// Récupère l'historique des paiements
Future<List<PaymentModel>> getPaymentHistory(Map<String, dynamic> filters) async {
try {
final response = await _dio.get('/api/paiements/historique', queryParameters: filters);
if (response.data is List) {
return (response.data as List)
.map((json) => PaymentModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour l\'historique des paiements');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération de l\'historique');
}
}
/// Vérifie le statut d'un service de paiement
Future<Map<String, dynamic>> checkServiceStatus(String serviceType) async {
try {
final response = await _dio.get('/api/paiements/services/$serviceType/statut');
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la vérification du service');
}
}
/// Récupère les statistiques de paiement
Future<Map<String, dynamic>> getPaymentStatistics(Map<String, dynamic> filters) async {
try {
final response = await _dio.get('/api/paiements/statistiques', queryParameters: filters);
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques');
}
}
}

View File

@@ -1,249 +0,0 @@
import 'dart:convert';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/cotisation_model.dart';
import '../models/cotisation_statistics_model.dart';
import '../models/payment_model.dart';
/// Service de gestion du cache local
/// Permet de stocker et récupérer des données en mode hors-ligne
@LazySingleton()
class CacheService {
static const String _cotisationsCacheKey = 'cotisations_cache';
static const String _cotisationsStatsCacheKey = 'cotisations_stats_cache';
static const String _paymentsCacheKey = 'payments_cache';
static const String _lastSyncKey = 'last_sync_timestamp';
static const Duration _cacheValidityDuration = Duration(minutes: 30);
final SharedPreferences _prefs;
CacheService(this._prefs);
/// Sauvegarde une liste de cotisations dans le cache
Future<void> saveCotisations(List<CotisationModel> cotisations, {String? key}) async {
final cacheKey = key ?? _cotisationsCacheKey;
final jsonList = cotisations.map((c) => c.toJson()).toList();
final jsonString = jsonEncode({
'data': jsonList,
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
await _prefs.setString(cacheKey, jsonString);
}
/// Récupère une liste de cotisations depuis le cache
Future<List<CotisationModel>?> getCotisations({String? key}) async {
final cacheKey = key ?? _cotisationsCacheKey;
final jsonString = _prefs.getString(cacheKey);
if (jsonString == null) return null;
try {
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
final timestamp = DateTime.fromMillisecondsSinceEpoch(jsonData['timestamp'] as int);
// Vérifier si le cache est encore valide
if (DateTime.now().difference(timestamp) > _cacheValidityDuration) {
await clearCotisations(key: key);
return null;
}
final jsonList = jsonData['data'] as List<dynamic>;
return jsonList.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>)).toList();
} catch (e) {
// En cas d'erreur, nettoyer le cache corrompu
await clearCotisations(key: key);
return null;
}
}
/// Sauvegarde les statistiques des cotisations
Future<void> saveCotisationsStats(CotisationStatisticsModel stats) async {
final jsonString = jsonEncode({
'data': stats.toJson(),
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
await _prefs.setString(_cotisationsStatsCacheKey, jsonString);
}
/// Récupère les statistiques des cotisations depuis le cache
Future<CotisationStatisticsModel?> getCotisationsStats() async {
final jsonString = _prefs.getString(_cotisationsStatsCacheKey);
if (jsonString == null) return null;
try {
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
final timestamp = DateTime.fromMillisecondsSinceEpoch(jsonData['timestamp'] as int);
// Vérifier si le cache est encore valide
if (DateTime.now().difference(timestamp) > _cacheValidityDuration) {
await clearCotisationsStats();
return null;
}
return CotisationStatisticsModel.fromJson(jsonData['data'] as Map<String, dynamic>);
} catch (e) {
await clearCotisationsStats();
return null;
}
}
/// Sauvegarde une liste de paiements dans le cache
Future<void> savePayments(List<PaymentModel> payments) async {
final jsonList = payments.map((p) => p.toJson()).toList();
final jsonString = jsonEncode({
'data': jsonList,
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
await _prefs.setString(_paymentsCacheKey, jsonString);
}
/// Récupère une liste de paiements depuis le cache
Future<List<PaymentModel>?> getPayments() async {
final jsonString = _prefs.getString(_paymentsCacheKey);
if (jsonString == null) return null;
try {
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
final timestamp = DateTime.fromMillisecondsSinceEpoch(jsonData['timestamp'] as int);
// Vérifier si le cache est encore valide
if (DateTime.now().difference(timestamp) > _cacheValidityDuration) {
await clearPayments();
return null;
}
final jsonList = jsonData['data'] as List<dynamic>;
return jsonList.map((json) => PaymentModel.fromJson(json as Map<String, dynamic>)).toList();
} catch (e) {
await clearPayments();
return null;
}
}
/// Sauvegarde une cotisation individuelle dans le cache
Future<void> saveCotisation(CotisationModel cotisation) async {
final key = 'cotisation_${cotisation.id}';
final jsonString = jsonEncode({
'data': cotisation.toJson(),
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
await _prefs.setString(key, jsonString);
}
/// Récupère une cotisation individuelle depuis le cache
Future<CotisationModel?> getCotisation(String id) async {
final key = 'cotisation_$id';
final jsonString = _prefs.getString(key);
if (jsonString == null) return null;
try {
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
final timestamp = DateTime.fromMillisecondsSinceEpoch(jsonData['timestamp'] as int);
// Vérifier si le cache est encore valide
if (DateTime.now().difference(timestamp) > _cacheValidityDuration) {
await clearCotisation(id);
return null;
}
return CotisationModel.fromJson(jsonData['data'] as Map<String, dynamic>);
} catch (e) {
await clearCotisation(id);
return null;
}
}
/// Met à jour le timestamp de la dernière synchronisation
Future<void> updateLastSyncTimestamp() async {
await _prefs.setInt(_lastSyncKey, DateTime.now().millisecondsSinceEpoch);
}
/// Récupère le timestamp de la dernière synchronisation
DateTime? getLastSyncTimestamp() {
final timestamp = _prefs.getInt(_lastSyncKey);
return timestamp != null ? DateTime.fromMillisecondsSinceEpoch(timestamp) : null;
}
/// Vérifie si une synchronisation est nécessaire
bool needsSync() {
final lastSync = getLastSyncTimestamp();
if (lastSync == null) return true;
return DateTime.now().difference(lastSync) > const Duration(minutes: 15);
}
/// Nettoie le cache des cotisations
Future<void> clearCotisations({String? key}) async {
final cacheKey = key ?? _cotisationsCacheKey;
await _prefs.remove(cacheKey);
}
/// Nettoie le cache des statistiques
Future<void> clearCotisationsStats() async {
await _prefs.remove(_cotisationsStatsCacheKey);
}
/// Nettoie le cache des paiements
Future<void> clearPayments() async {
await _prefs.remove(_paymentsCacheKey);
}
/// Nettoie une cotisation individuelle du cache
Future<void> clearCotisation(String id) async {
final key = 'cotisation_$id';
await _prefs.remove(key);
}
/// Nettoie tout le cache des cotisations
Future<void> clearAllCotisationsCache() async {
final keys = _prefs.getKeys().where((key) =>
key.startsWith('cotisation') ||
key == _cotisationsStatsCacheKey ||
key == _paymentsCacheKey
).toList();
for (final key in keys) {
await _prefs.remove(key);
}
}
/// Retourne la taille du cache en octets (approximation)
int getCacheSize() {
int totalSize = 0;
final keys = _prefs.getKeys().where((key) =>
key.startsWith('cotisation') ||
key == _cotisationsStatsCacheKey ||
key == _paymentsCacheKey
);
for (final key in keys) {
final value = _prefs.getString(key);
if (value != null) {
totalSize += value.length * 2; // Approximation UTF-16
}
}
return totalSize;
}
/// Retourne des informations sur le cache
Map<String, dynamic> getCacheInfo() {
final lastSync = getLastSyncTimestamp();
return {
'lastSync': lastSync?.toIso8601String(),
'needsSync': needsSync(),
'cacheSize': getCacheSize(),
'cacheSizeFormatted': _formatBytes(getCacheSize()),
};
}
/// Formate la taille en octets en format lisible
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}

View File

@@ -1,258 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:permission_handler/permission_handler.dart';
import '../models/membre_model.dart';
import '../../shared/theme/app_theme.dart';
/// Service de gestion des communications (appels, SMS, emails)
/// Gère les permissions et l'intégration avec les applications natives
class CommunicationService {
static final CommunicationService _instance = CommunicationService._internal();
factory CommunicationService() => _instance;
CommunicationService._internal();
/// Effectue un appel téléphonique vers un membre
Future<bool> callMember(BuildContext context, MembreModel membre) async {
try {
// Vérifier si le numéro de téléphone est valide
if (membre.telephone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone non disponible pour ${membre.nomComplet}');
return false;
}
// Nettoyer le numéro de téléphone
final cleanPhone = _cleanPhoneNumber(membre.telephone);
if (cleanPhone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone invalide pour ${membre.nomComplet}');
return false;
}
// Vérifier les permissions sur Android
if (Platform.isAndroid) {
final phonePermission = await Permission.phone.status;
if (phonePermission.isDenied) {
final result = await Permission.phone.request();
if (result.isDenied) {
_showPermissionDeniedDialog(context, 'Téléphone', 'effectuer des appels');
return false;
}
}
}
// Construire l'URL d'appel
final phoneUrl = Uri.parse('tel:$cleanPhone');
// Vérifier si l'application peut gérer les appels
if (await canLaunchUrl(phoneUrl)) {
// Feedback haptique
HapticFeedback.mediumImpact();
// Lancer l'appel
final success = await launchUrl(phoneUrl);
if (success) {
_showSuccessSnackBar(context, 'Appel lancé vers ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('📞 Appel effectué vers ${membre.nomComplet} (${membre.telephone})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible de lancer l\'appel vers ${membre.nomComplet}');
return false;
}
} else {
_showErrorSnackBar(context, 'Application d\'appel non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'appel vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'appel vers ${membre.nomComplet}');
return false;
}
}
/// Envoie un SMS à un membre
Future<bool> sendSMS(BuildContext context, MembreModel membre, {String? message}) async {
try {
// Vérifier si le numéro de téléphone est valide
if (membre.telephone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone non disponible pour ${membre.nomComplet}');
return false;
}
// Nettoyer le numéro de téléphone
final cleanPhone = _cleanPhoneNumber(membre.telephone);
if (cleanPhone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone invalide pour ${membre.nomComplet}');
return false;
}
// Construire l'URL SMS
String smsUrl = 'sms:$cleanPhone';
if (message != null && message.isNotEmpty) {
final encodedMessage = Uri.encodeComponent(message);
smsUrl += '?body=$encodedMessage';
}
final smsUri = Uri.parse(smsUrl);
// Vérifier si l'application peut gérer les SMS
if (await canLaunchUrl(smsUri)) {
// Feedback haptique
HapticFeedback.lightImpact();
// Lancer l'application SMS
final success = await launchUrl(smsUri);
if (success) {
_showSuccessSnackBar(context, 'SMS ouvert pour ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('💬 SMS ouvert pour ${membre.nomComplet} (${membre.telephone})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible d\'ouvrir l\'application SMS');
return false;
}
} else {
_showErrorSnackBar(context, 'Application SMS non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi SMS vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'envoi SMS vers ${membre.nomComplet}');
return false;
}
}
/// Envoie un email à un membre
Future<bool> sendEmail(BuildContext context, MembreModel membre, {String? subject, String? body}) async {
try {
// Vérifier si l'email est valide
if (membre.email.isEmpty) {
_showErrorSnackBar(context, 'Adresse email non disponible pour ${membre.nomComplet}');
return false;
}
// Construire l'URL email
String emailUrl = 'mailto:${membre.email}';
final params = <String>[];
if (subject != null && subject.isNotEmpty) {
params.add('subject=${Uri.encodeComponent(subject)}');
}
if (body != null && body.isNotEmpty) {
params.add('body=${Uri.encodeComponent(body)}');
}
if (params.isNotEmpty) {
emailUrl += '?${params.join('&')}';
}
final emailUri = Uri.parse(emailUrl);
// Vérifier si l'application peut gérer les emails
if (await canLaunchUrl(emailUri)) {
// Feedback haptique
HapticFeedback.lightImpact();
// Lancer l'application email
final success = await launchUrl(emailUri);
if (success) {
_showSuccessSnackBar(context, 'Email ouvert pour ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('📧 Email ouvert pour ${membre.nomComplet} (${membre.email})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible d\'ouvrir l\'application email');
return false;
}
} else {
_showErrorSnackBar(context, 'Application email non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi email vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'envoi email vers ${membre.nomComplet}');
return false;
}
}
/// Nettoie un numéro de téléphone en supprimant les caractères non numériques
String _cleanPhoneNumber(String phone) {
// Garder seulement les chiffres et le signe +
final cleaned = phone.replaceAll(RegExp(r'[^\d+]'), '');
// Vérifier que le numéro n'est pas vide après nettoyage
if (cleaned.isEmpty) return '';
// Si le numéro commence par +, le garder tel quel
if (cleaned.startsWith('+')) return cleaned;
// Si le numéro commence par 00, le remplacer par +
if (cleaned.startsWith('00')) {
return '+${cleaned.substring(2)}';
}
return cleaned;
}
/// Affiche un SnackBar de succès
void _showSuccessSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
/// Affiche un SnackBar d'erreur
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
/// Affiche une dialog pour les permissions refusées
void _showPermissionDeniedDialog(BuildContext context, String permission, String action) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Permission $permission requise'),
content: Text(
'L\'application a besoin de la permission $permission pour $action. '
'Veuillez autoriser cette permission dans les paramètres de l\'application.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
openAppSettings();
},
child: const Text('Paramètres'),
),
],
),
);
}
}

View File

@@ -1,775 +0,0 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:excel/excel.dart';
import 'package:csv/csv.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:share_plus/share_plus.dart';
import '../models/membre_model.dart';
import '../../shared/theme/app_theme.dart';
/// Options d'export
class ExportOptions {
final String format;
final bool includePersonalInfo;
final bool includeContactInfo;
final bool includeAdhesionInfo;
final bool includeStatistics;
final bool includeInactiveMembers;
const ExportOptions({
required this.format,
this.includePersonalInfo = true,
this.includeContactInfo = true,
this.includeAdhesionInfo = true,
this.includeStatistics = false,
this.includeInactiveMembers = true,
});
}
/// Service de gestion de l'export et import des données
/// Supporte les formats Excel, CSV, PDF et JSON
class ExportImportService {
static final ExportImportService _instance = ExportImportService._internal();
factory ExportImportService() => _instance;
ExportImportService._internal();
/// Exporte une liste de membres selon les options spécifiées
Future<String?> exportMembers(
BuildContext context,
List<MembreModel> members,
ExportOptions options,
) async {
try {
// Filtrer les membres selon les options
List<MembreModel> filteredMembers = members;
if (!options.includeInactiveMembers) {
filteredMembers = filteredMembers.where((m) => m.actif).toList();
}
// Générer le fichier selon le format
String? filePath;
switch (options.format.toLowerCase()) {
case 'excel':
filePath = await _exportToExcel(filteredMembers, options);
break;
case 'csv':
filePath = await _exportToCsv(filteredMembers, options);
break;
case 'pdf':
filePath = await _exportToPdf(filteredMembers, options);
break;
case 'json':
filePath = await _exportToJson(filteredMembers, options);
break;
default:
throw Exception('Format d\'export non supporté: ${options.format}');
}
if (filePath != null) {
// Feedback haptique
HapticFeedback.mediumImpact();
// Afficher le résultat
_showExportSuccess(context, filteredMembers.length, options.format, filePath);
// Log de l'action
debugPrint('📤 Export réussi: ${filteredMembers.length} membres en ${options.format.toUpperCase()} -> $filePath');
return filePath;
} else {
_showExportError(context, 'Impossible de créer le fichier d\'export');
return null;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'export: $e');
_showExportError(context, 'Erreur lors de l\'export: ${e.toString()}');
return null;
}
}
/// Exporte vers Excel
Future<String?> _exportToExcel(List<MembreModel> members, ExportOptions options) async {
try {
final excel = Excel.createExcel();
final sheet = excel['Membres'];
// Supprimer la feuille par défaut
excel.delete('Sheet1');
// En-têtes
final headers = _buildHeaders(options);
for (int i = 0; i < headers.length; i++) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0)).value =
TextCellValue(headers[i]);
}
// Données
for (int rowIndex = 0; rowIndex < members.length; rowIndex++) {
final member = members[rowIndex];
final rowData = _buildRowData(member, options);
for (int colIndex = 0; colIndex < rowData.length; colIndex++) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: colIndex, rowIndex: rowIndex + 1)).value =
TextCellValue(rowData[colIndex]);
}
}
// Sauvegarder le fichier
final directory = await getApplicationDocumentsDirectory();
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.xlsx';
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsBytes(excel.encode()!);
return filePath;
} catch (e) {
debugPrint('❌ Erreur export Excel: $e');
return null;
}
}
/// Exporte vers CSV
Future<String?> _exportToCsv(List<MembreModel> members, ExportOptions options) async {
try {
final headers = _buildHeaders(options);
final rows = <List<String>>[headers];
for (final member in members) {
rows.add(_buildRowData(member, options));
}
final csvData = const ListToCsvConverter().convert(rows);
// Sauvegarder le fichier
final directory = await getApplicationDocumentsDirectory();
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.csv';
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsString(csvData, encoding: utf8);
return filePath;
} catch (e) {
debugPrint('❌ Erreur export CSV: $e');
return null;
}
}
/// Exporte vers PDF
Future<String?> _exportToPdf(List<MembreModel> members, ExportOptions options) async {
try {
final pdf = pw.Document();
// Créer le contenu PDF
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
build: (pw.Context context) {
return [
pw.Header(
level: 0,
child: pw.Text(
'Liste des Membres UnionFlow',
style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold),
),
),
pw.SizedBox(height: 20),
pw.Text(
'Exporté le ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year} à ${DateTime.now().hour}:${DateTime.now().minute}',
style: const pw.TextStyle(fontSize: 12),
),
pw.SizedBox(height: 20),
pw.Table.fromTextArray(
headers: _buildHeaders(options),
data: members.map((member) => _buildRowData(member, options)).toList(),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
cellStyle: const pw.TextStyle(fontSize: 10),
cellAlignment: pw.Alignment.centerLeft,
),
];
},
),
);
// Sauvegarder le fichier
final directory = await getApplicationDocumentsDirectory();
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.pdf';
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsBytes(await pdf.save());
return filePath;
} catch (e) {
debugPrint('❌ Erreur export PDF: $e');
return null;
}
}
/// Exporte vers JSON
Future<String?> _exportToJson(List<MembreModel> members, ExportOptions options) async {
try {
final data = {
'exportInfo': {
'date': DateTime.now().toIso8601String(),
'format': 'JSON',
'totalMembers': members.length,
'options': {
'includePersonalInfo': options.includePersonalInfo,
'includeContactInfo': options.includeContactInfo,
'includeAdhesionInfo': options.includeAdhesionInfo,
'includeStatistics': options.includeStatistics,
'includeInactiveMembers': options.includeInactiveMembers,
},
},
'members': members.map((member) => _buildJsonData(member, options)).toList(),
};
final jsonString = const JsonEncoder.withIndent(' ').convert(data);
// Sauvegarder le fichier
final directory = await getApplicationDocumentsDirectory();
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.json';
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsString(jsonString, encoding: utf8);
return filePath;
} catch (e) {
debugPrint('❌ Erreur export JSON: $e');
return null;
}
}
/// Construit les en-têtes selon les options
List<String> _buildHeaders(ExportOptions options) {
final headers = <String>[];
if (options.includePersonalInfo) {
headers.addAll(['Numéro', 'Nom', 'Prénom', 'Date de naissance', 'Profession']);
}
if (options.includeContactInfo) {
headers.addAll(['Téléphone', 'Email', 'Adresse', 'Ville', 'Code postal', 'Pays']);
}
if (options.includeAdhesionInfo) {
headers.addAll(['Date d\'adhésion', 'Statut', 'Actif']);
}
if (options.includeStatistics) {
headers.addAll(['Âge', 'Ancienneté (jours)', 'Date création', 'Date modification']);
}
return headers;
}
/// Construit les données d'une ligne selon les options
List<String> _buildRowData(MembreModel member, ExportOptions options) {
final rowData = <String>[];
if (options.includePersonalInfo) {
rowData.addAll([
member.numeroMembre,
member.nom,
member.prenom,
member.dateNaissance?.toIso8601String().split('T')[0] ?? '',
member.profession ?? '',
]);
}
if (options.includeContactInfo) {
rowData.addAll([
member.telephone,
member.email,
member.adresse ?? '',
member.ville ?? '',
member.codePostal ?? '',
member.pays ?? '',
]);
}
if (options.includeAdhesionInfo) {
rowData.addAll([
member.dateAdhesion.toIso8601String().split('T')[0],
member.statut,
member.actif ? 'Oui' : 'Non',
]);
}
if (options.includeStatistics) {
final age = member.age.toString();
final anciennete = DateTime.now().difference(member.dateAdhesion).inDays.toString();
final dateCreation = member.dateCreation.toIso8601String().split('T')[0];
final dateModification = member.dateModification?.toIso8601String().split('T')[0] ?? 'N/A';
rowData.addAll([age, anciennete, dateCreation, dateModification]);
}
return rowData;
}
/// Construit les données JSON selon les options
Map<String, dynamic> _buildJsonData(MembreModel member, ExportOptions options) {
final data = <String, dynamic>{};
if (options.includePersonalInfo) {
data.addAll({
'numeroMembre': member.numeroMembre,
'nom': member.nom,
'prenom': member.prenom,
'dateNaissance': member.dateNaissance?.toIso8601String(),
'profession': member.profession,
});
}
if (options.includeContactInfo) {
data.addAll({
'telephone': member.telephone,
'email': member.email,
'adresse': member.adresse,
'ville': member.ville,
'codePostal': member.codePostal,
'pays': member.pays,
});
}
if (options.includeAdhesionInfo) {
data.addAll({
'dateAdhesion': member.dateAdhesion.toIso8601String(),
'statut': member.statut,
'actif': member.actif,
});
}
if (options.includeStatistics) {
data.addAll({
'age': member.age,
'ancienneteEnJours': DateTime.now().difference(member.dateAdhesion).inDays,
'dateCreation': member.dateCreation.toIso8601String(),
'dateModification': member.dateModification?.toIso8601String(),
});
}
return data;
}
/// Affiche le succès de l'export
void _showExportSuccess(BuildContext context, int count, String format, String filePath) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Export ${format.toUpperCase()} réussi: $count membres',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'Partager',
textColor: Colors.white,
onPressed: () => _shareFile(filePath),
),
),
);
}
/// Affiche l'erreur d'export
void _showExportError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(child: Text(message)),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 5),
),
);
}
/// Partage un fichier
Future<void> _shareFile(String filePath) async {
try {
await Share.shareXFiles([XFile(filePath)]);
} catch (e) {
debugPrint('❌ Erreur lors du partage: $e');
}
}
/// Importe des membres depuis un fichier
Future<List<MembreModel>?> importMembers(BuildContext context) async {
try {
// Sélectionner le fichier
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xlsx', 'csv', 'json'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
return null;
}
final file = result.files.first;
final filePath = file.path;
if (filePath == null) {
_showImportError(context, 'Impossible de lire le fichier sélectionné');
return null;
}
// Importer selon l'extension
List<MembreModel>? importedMembers;
final extension = file.extension?.toLowerCase();
switch (extension) {
case 'xlsx':
importedMembers = await _importFromExcel(filePath);
break;
case 'csv':
importedMembers = await _importFromCsv(filePath);
break;
case 'json':
importedMembers = await _importFromJson(filePath);
break;
default:
_showImportError(context, 'Format de fichier non supporté: $extension');
return null;
}
if (importedMembers != null && importedMembers.isNotEmpty) {
// Feedback haptique
HapticFeedback.mediumImpact();
// Afficher le résultat
_showImportSuccess(context, importedMembers.length, extension!);
// Log de l'action
debugPrint('📥 Import réussi: ${importedMembers.length} membres depuis ${extension.toUpperCase()}');
return importedMembers;
} else {
_showImportError(context, 'Aucun membre valide trouvé dans le fichier');
return null;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'import: $e');
_showImportError(context, 'Erreur lors de l\'import: ${e.toString()}');
return null;
}
}
/// Importe depuis Excel
Future<List<MembreModel>?> _importFromExcel(String filePath) async {
try {
final file = File(filePath);
final bytes = await file.readAsBytes();
final excel = Excel.decodeBytes(bytes);
final sheet = excel.tables.values.first;
if (sheet == null || sheet.rows.isEmpty) {
return null;
}
final members = <MembreModel>[];
// Ignorer la première ligne (en-têtes)
for (int i = 1; i < sheet.rows.length; i++) {
final row = sheet.rows[i];
if (row.isEmpty) continue;
try {
final member = _parseRowToMember(row.map((cell) => cell?.value?.toString() ?? '').toList());
if (member != null) {
members.add(member);
}
} catch (e) {
debugPrint('⚠️ Erreur ligne $i: $e');
}
}
return members;
} catch (e) {
debugPrint('❌ Erreur import Excel: $e');
return null;
}
}
/// Importe depuis CSV
Future<List<MembreModel>?> _importFromCsv(String filePath) async {
try {
final file = File(filePath);
final content = await file.readAsString(encoding: utf8);
final rows = const CsvToListConverter().convert(content);
if (rows.isEmpty) {
return null;
}
final members = <MembreModel>[];
// Ignorer la première ligne (en-têtes)
for (int i = 1; i < rows.length; i++) {
final row = rows[i];
if (row.isEmpty) continue;
try {
final member = _parseRowToMember(row.map((cell) => cell?.toString() ?? '').toList());
if (member != null) {
members.add(member);
}
} catch (e) {
debugPrint('⚠️ Erreur ligne $i: $e');
}
}
return members;
} catch (e) {
debugPrint('❌ Erreur import CSV: $e');
return null;
}
}
/// Importe depuis JSON
Future<List<MembreModel>?> _importFromJson(String filePath) async {
try {
final file = File(filePath);
final content = await file.readAsString(encoding: utf8);
final data = jsonDecode(content) as Map<String, dynamic>;
final membersData = data['members'] as List<dynamic>?;
if (membersData == null || membersData.isEmpty) {
return null;
}
final members = <MembreModel>[];
for (final memberData in membersData) {
try {
final member = _parseJsonToMember(memberData as Map<String, dynamic>);
if (member != null) {
members.add(member);
}
} catch (e) {
debugPrint('⚠️ Erreur membre JSON: $e');
}
}
return members;
} catch (e) {
debugPrint('❌ Erreur import JSON: $e');
return null;
}
}
/// Affiche le succès de l'import
void _showImportSuccess(BuildContext context, int count, String format) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Import ${format.toUpperCase()} réussi: $count membres',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 4),
),
);
}
/// Affiche l'erreur d'import
void _showImportError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(child: Text(message)),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 5),
),
);
}
/// Parse une ligne de données vers un MembreModel
MembreModel? _parseRowToMember(List<String> row) {
if (row.length < 7) return null; // Minimum requis
try {
// Parser la date de naissance
DateTime? dateNaissance;
if (row.length > 3 && row[3].isNotEmpty) {
try {
dateNaissance = DateTime.parse(row[3]);
} catch (e) {
// Essayer d'autres formats de date
try {
final parts = row[3].split('/');
if (parts.length == 3) {
dateNaissance = DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
}
} catch (e) {
debugPrint('⚠️ Format de date non reconnu: ${row[3]}');
}
}
}
// Parser la date d'adhésion
DateTime dateAdhesion = DateTime.now();
if (row.length > 12 && row[12].isNotEmpty) {
try {
dateAdhesion = DateTime.parse(row[12]);
} catch (e) {
try {
final parts = row[12].split('/');
if (parts.length == 3) {
dateAdhesion = DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
}
} catch (e) {
debugPrint('⚠️ Format de date d\'adhésion non reconnu: ${row[12]}');
}
}
}
return MembreModel(
id: 'import_${DateTime.now().millisecondsSinceEpoch}_${row.hashCode}',
numeroMembre: row[0].isNotEmpty ? row[0] : 'AUTO-${DateTime.now().millisecondsSinceEpoch}',
nom: row[1],
prenom: row[2],
email: row.length > 8 ? row[8] : '',
telephone: row.length > 7 ? row[7] : '',
dateNaissance: dateNaissance,
profession: row.length > 6 ? row[6] : null,
adresse: row.length > 9 ? row[9] : null,
ville: row.length > 10 ? row[10] : null,
pays: row.length > 11 ? row[11] : 'Côte d\'Ivoire',
statut: row.length > 13 ? (row[13].toLowerCase() == 'actif' ? 'ACTIF' : 'INACTIF') : 'ACTIF',
dateAdhesion: dateAdhesion,
dateCreation: DateTime.now(),
actif: row.length > 13 ? (row[13].toLowerCase() == 'actif' || row[13].toLowerCase() == 'true') : true,
version: 1,
);
} catch (e) {
debugPrint('⚠️ Erreur parsing ligne: $e');
return null;
}
}
/// Parse des données JSON vers un MembreModel
MembreModel? _parseJsonToMember(Map<String, dynamic> data) {
try {
// Parser la date de naissance
DateTime? dateNaissance;
if (data['dateNaissance'] != null) {
try {
if (data['dateNaissance'] is String) {
dateNaissance = DateTime.parse(data['dateNaissance']);
} else if (data['dateNaissance'] is DateTime) {
dateNaissance = data['dateNaissance'];
}
} catch (e) {
debugPrint('⚠️ Format de date de naissance JSON non reconnu: ${data['dateNaissance']}');
}
}
// Parser la date d'adhésion
DateTime dateAdhesion = DateTime.now();
if (data['dateAdhesion'] != null) {
try {
if (data['dateAdhesion'] is String) {
dateAdhesion = DateTime.parse(data['dateAdhesion']);
} else if (data['dateAdhesion'] is DateTime) {
dateAdhesion = data['dateAdhesion'];
}
} catch (e) {
debugPrint('⚠️ Format de date d\'adhésion JSON non reconnu: ${data['dateAdhesion']}');
}
}
// Parser la date de création
DateTime dateCreation = DateTime.now();
if (data['dateCreation'] != null) {
try {
if (data['dateCreation'] is String) {
dateCreation = DateTime.parse(data['dateCreation']);
} else if (data['dateCreation'] is DateTime) {
dateCreation = data['dateCreation'];
}
} catch (e) {
debugPrint('⚠️ Format de date de création JSON non reconnu: ${data['dateCreation']}');
}
}
return MembreModel(
id: data['id'] ?? 'import_${DateTime.now().millisecondsSinceEpoch}_${data.hashCode}',
numeroMembre: data['numeroMembre'] ?? 'AUTO-${DateTime.now().millisecondsSinceEpoch}',
nom: data['nom'] ?? '',
prenom: data['prenom'] ?? '',
email: data['email'] ?? '',
telephone: data['telephone'] ?? '',
dateNaissance: dateNaissance,
profession: data['profession'],
adresse: data['adresse'],
ville: data['ville'],
pays: data['pays'] ?? 'Côte d\'Ivoire',
statut: data['statut'] ?? 'ACTIF',
dateAdhesion: dateAdhesion,
dateCreation: dateCreation,
actif: data['actif'] ?? true,
version: data['version'] ?? 1,
);
} catch (e) {
debugPrint('⚠️ Erreur parsing JSON: $e');
return null;
}
}
/// Valide un membre importé
bool _validateImportedMember(MembreModel member) {
// Validation basique
if (member.nom.isEmpty || member.prenom.isEmpty) {
return false;
}
// Validation email si fourni
if (member.email.isNotEmpty && !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(member.email)) {
return false;
}
// Validation téléphone si fourni
if (member.telephone.isNotEmpty && !RegExp(r'^\+?[\d\s\-\(\)]{8,}$').hasMatch(member.telephone)) {
return false;
}
return true;
}
}

View File

@@ -1,280 +0,0 @@
import 'package:injectable/injectable.dart';
import '../models/payment_model.dart';
import 'api_service.dart';
/// Service d'intégration avec Moov Money
/// Gère les paiements via Moov Money pour la Côte d'Ivoire
@LazySingleton()
class MoovMoneyService {
final ApiService _apiService;
MoovMoneyService(this._apiService);
/// Initie un paiement Moov Money pour une cotisation
Future<PaymentModel> initiatePayment({
required String cotisationId,
required double montant,
required String numeroTelephone,
String? nomPayeur,
String? emailPayeur,
}) async {
try {
final paymentData = {
'cotisationId': cotisationId,
'montant': montant,
'methodePaiement': 'MOOV_MONEY',
'numeroTelephone': numeroTelephone,
'nomPayeur': nomPayeur,
'emailPayeur': emailPayeur,
};
// Appel API pour initier le paiement Moov Money
final payment = await _apiService.initiatePayment(paymentData);
return payment;
} catch (e) {
throw MoovMoneyException('Erreur lors de l\'initiation du paiement Moov Money: ${e.toString()}');
}
}
/// Vérifie le statut d'un paiement Moov Money
Future<PaymentModel> checkPaymentStatus(String paymentId) async {
try {
return await _apiService.getPaymentStatus(paymentId);
} catch (e) {
throw MoovMoneyException('Erreur lors de la vérification du statut: ${e.toString()}');
}
}
/// Calcule les frais Moov Money selon le barème officiel
double calculateMoovMoneyFees(double montant) {
// Barème Moov Money Côte d'Ivoire (2024)
if (montant <= 1000) return 0; // Gratuit jusqu'à 1000 XOF
if (montant <= 5000) return 30; // 30 XOF de 1001 à 5000
if (montant <= 15000) return 75; // 75 XOF de 5001 à 15000
if (montant <= 50000) return 150; // 150 XOF de 15001 à 50000
if (montant <= 100000) return 300; // 300 XOF de 50001 à 100000
if (montant <= 250000) return 600; // 600 XOF de 100001 à 250000
if (montant <= 500000) return 1200; // 1200 XOF de 250001 à 500000
// Au-delà de 500000 XOF: 0.4% du montant
return montant * 0.004;
}
/// Valide un numéro de téléphone Moov Money
bool validatePhoneNumber(String numeroTelephone) {
// Nettoyer le numéro
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
// Moov Money: 01, 02, 03 (Côte d'Ivoire)
// Format: 225XXXXXXXX ou 0XXXXXXXX
return RegExp(r'^(225)?(0[123])\d{8}$').hasMatch(cleanNumber);
}
/// Obtient les limites de transaction Moov Money
Map<String, double> getTransactionLimits() {
return {
'montantMinimum': 100.0, // 100 XOF minimum
'montantMaximum': 1500000.0, // 1.5 million XOF maximum
'fraisMinimum': 0.0,
'fraisMaximum': 6000.0, // Frais maximum théorique
};
}
/// Vérifie si un montant est dans les limites autorisées
bool isAmountValid(double montant) {
final limits = getTransactionLimits();
return montant >= limits['montantMinimum']! &&
montant <= limits['montantMaximum']!;
}
/// Formate un numéro de téléphone pour Moov Money
String formatPhoneNumber(String numeroTelephone) {
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
// Si le numéro commence par 225, le garder tel quel
if (cleanNumber.startsWith('225')) {
return cleanNumber;
}
// Si le numéro commence par 0, ajouter 225
if (cleanNumber.startsWith('0')) {
return '225$cleanNumber';
}
// Sinon, ajouter 2250
return '2250$cleanNumber';
}
/// Obtient les informations de l'opérateur
Map<String, dynamic> getOperatorInfo() {
return {
'nom': 'Moov Money',
'code': 'MOOV_MONEY',
'couleur': '#0066CC',
'icone': '💙',
'description': 'Paiement via Moov Money',
'prefixes': ['01', '02', '03'],
'pays': 'Côte d\'Ivoire',
'devise': 'XOF',
};
}
/// Génère un message de confirmation pour l'utilisateur
String generateConfirmationMessage({
required double montant,
required String numeroTelephone,
required double frais,
}) {
final total = montant + frais;
final formattedPhone = formatPhoneNumber(numeroTelephone);
return '''
Confirmation de paiement Moov Money
Montant: ${montant.toStringAsFixed(0)} XOF
Frais: ${frais.toStringAsFixed(0)} XOF
Total: ${total.toStringAsFixed(0)} XOF
Numéro: $formattedPhone
Vous allez recevoir un SMS avec le code de confirmation.
Composez *155# pour finaliser le paiement.
''';
}
/// Annule un paiement Moov Money (si possible)
Future<bool> cancelPayment(String paymentId) async {
try {
// Vérifier le statut du paiement
final payment = await checkPaymentStatus(paymentId);
// Un paiement peut être annulé seulement s'il est en attente
if (payment.statut == 'EN_ATTENTE') {
// Appeler l'API d'annulation
await _apiService.cancelPayment(paymentId);
return true;
}
return false;
} catch (e) {
return false;
}
}
/// Obtient l'historique des paiements Moov Money
Future<List<PaymentModel>> getPaymentHistory({
String? cotisationId,
DateTime? dateDebut,
DateTime? dateFin,
int? limit,
}) async {
try {
final filters = <String, dynamic>{
'methodePaiement': 'MOOV_MONEY',
if (cotisationId != null) 'cotisationId': cotisationId,
if (dateDebut != null) 'dateDebut': dateDebut.toIso8601String(),
if (dateFin != null) 'dateFin': dateFin.toIso8601String(),
if (limit != null) 'limit': limit,
};
return await _apiService.getPaymentHistory(filters);
} catch (e) {
throw MoovMoneyException('Erreur lors de la récupération de l\'historique: ${e.toString()}');
}
}
/// Vérifie la disponibilité du service Moov Money
Future<bool> checkServiceAvailability() async {
try {
// Appel API pour vérifier la disponibilité
final response = await _apiService.checkServiceStatus('MOOV_MONEY');
return response['available'] == true;
} catch (e) {
// En cas d'erreur, considérer le service comme indisponible
return false;
}
}
/// Obtient les statistiques des paiements Moov Money
Future<Map<String, dynamic>> getPaymentStatistics({
DateTime? dateDebut,
DateTime? dateFin,
}) async {
try {
final filters = <String, dynamic>{
'methodePaiement': 'MOOV_MONEY',
if (dateDebut != null) 'dateDebut': dateDebut.toIso8601String(),
if (dateFin != null) 'dateFin': dateFin.toIso8601String(),
};
return await _apiService.getPaymentStatistics(filters);
} catch (e) {
throw MoovMoneyException('Erreur lors de la récupération des statistiques: ${e.toString()}');
}
}
/// Détecte automatiquement l'opérateur à partir du numéro
static String? detectOperatorFromNumber(String numeroTelephone) {
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
// Extraire les 2 premiers chiffres après 225 ou le préfixe 0
String prefix = '';
if (cleanNumber.startsWith('225') && cleanNumber.length >= 5) {
prefix = cleanNumber.substring(3, 5);
} else if (cleanNumber.startsWith('0') && cleanNumber.length >= 2) {
prefix = cleanNumber.substring(0, 2);
}
// Vérifier si c'est Moov Money
if (['01', '02', '03'].contains(prefix)) {
return 'MOOV_MONEY';
}
return null;
}
/// Obtient les horaires de service
Map<String, dynamic> getServiceHours() {
return {
'ouverture': '06:00',
'fermeture': '23:00',
'jours': ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'],
'maintenance': {
'debut': '02:00',
'fin': '04:00',
'description': 'Maintenance technique quotidienne'
}
};
}
/// Vérifie si le service est disponible à l'heure actuelle
bool isServiceAvailableNow() {
final now = DateTime.now();
final hour = now.hour;
// Service disponible de 6h à 23h
// Maintenance de 2h à 4h
if (hour >= 2 && hour < 4) {
return false; // Maintenance
}
return hour >= 6 && hour < 23;
}
}
/// Exception personnalisée pour les erreurs Moov Money
class MoovMoneyException implements Exception {
final String message;
final String? errorCode;
final dynamic originalError;
MoovMoneyException(
this.message, {
this.errorCode,
this.originalError,
});
@override
String toString() => 'MoovMoneyException: $message';
}

View File

@@ -1,362 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/cotisation_model.dart';
/// Service de gestion des notifications
/// Gère les notifications locales et push pour les cotisations
@LazySingleton()
class NotificationService {
static const String _notificationsEnabledKey = 'notifications_enabled';
static const String _reminderDaysKey = 'reminder_days';
static const String _scheduledNotificationsKey = 'scheduled_notifications';
final FlutterLocalNotificationsPlugin _localNotifications;
final SharedPreferences _prefs;
NotificationService(this._localNotifications, this._prefs);
/// Initialise le service de notifications
Future<void> initialize() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _localNotifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTapped,
);
// Demander les permissions sur iOS
await _requestPermissions();
}
/// Demande les permissions de notification
Future<bool> _requestPermissions() async {
final result = await _localNotifications
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
return result ?? true;
}
/// Planifie une notification de rappel pour une cotisation
Future<void> schedulePaymentReminder(CotisationModel cotisation) async {
if (!await isNotificationsEnabled()) return;
final reminderDays = await getReminderDays();
final notificationDate = cotisation.dateEcheance.subtract(Duration(days: reminderDays));
// Ne pas planifier si la date est déjà passée
if (notificationDate.isBefore(DateTime.now())) return;
const androidDetails = AndroidNotificationDetails(
'payment_reminders',
'Rappels de paiement',
channelDescription: 'Notifications de rappel pour les cotisations à payer',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
color: Color(0xFF2196F3),
playSound: true,
enableVibration: true,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
final notificationId = _generateNotificationId(cotisation.id, 'reminder');
await _localNotifications.zonedSchedule(
notificationId,
'Rappel de cotisation',
'Votre cotisation ${cotisation.typeCotisation} de ${cotisation.montantDu.toStringAsFixed(0)} XOF arrive à échéance le ${_formatDate(cotisation.dateEcheance)}',
_convertToTZDateTime(notificationDate),
notificationDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
payload: jsonEncode({
'type': 'payment_reminder',
'cotisationId': cotisation.id,
'action': 'open_cotisation',
}),
);
// Sauvegarder la notification planifiée
await _saveScheduledNotification(notificationId, cotisation.id, 'reminder', notificationDate);
}
/// Planifie une notification d'échéance le jour J
Future<void> scheduleDueDateNotification(CotisationModel cotisation) async {
if (!await isNotificationsEnabled()) return;
final notificationDate = DateTime(
cotisation.dateEcheance.year,
cotisation.dateEcheance.month,
cotisation.dateEcheance.day,
9, // 9h du matin
);
// Ne pas planifier si la date est déjà passée
if (notificationDate.isBefore(DateTime.now())) return;
const androidDetails = AndroidNotificationDetails(
'due_date_notifications',
'Échéances du jour',
channelDescription: 'Notifications pour les cotisations qui arrivent à échéance',
importance: Importance.max,
priority: Priority.max,
icon: '@mipmap/ic_launcher',
color: Color(0xFFFF5722),
playSound: true,
enableVibration: true,
ongoing: true,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
interruptionLevel: InterruptionLevel.critical,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
final notificationId = _generateNotificationId(cotisation.id, 'due_date');
await _localNotifications.zonedSchedule(
notificationId,
'Échéance aujourd\'hui !',
'Votre cotisation ${cotisation.typeCotisation} de ${cotisation.montantDu.toStringAsFixed(0)} XOF arrive à échéance aujourd\'hui',
_convertToTZDateTime(notificationDate),
notificationDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
payload: jsonEncode({
'type': 'due_date',
'cotisationId': cotisation.id,
'action': 'pay_now',
}),
);
await _saveScheduledNotification(notificationId, cotisation.id, 'due_date', notificationDate);
}
/// Envoie une notification immédiate de confirmation de paiement
Future<void> showPaymentConfirmation(CotisationModel cotisation, double montantPaye) async {
const androidDetails = AndroidNotificationDetails(
'payment_confirmations',
'Confirmations de paiement',
channelDescription: 'Notifications de confirmation après paiement',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
color: Color(0xFF4CAF50),
playSound: true,
enableVibration: true,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _localNotifications.show(
_generateNotificationId(cotisation.id, 'payment_success'),
'Paiement confirmé ✅',
'Votre paiement de ${montantPaye.toStringAsFixed(0)} XOF pour la cotisation ${cotisation.typeCotisation} a été confirmé',
notificationDetails,
payload: jsonEncode({
'type': 'payment_success',
'cotisationId': cotisation.id,
'action': 'view_receipt',
}),
);
}
/// Envoie une notification d'échec de paiement
Future<void> showPaymentFailure(CotisationModel cotisation, String raison) async {
const androidDetails = AndroidNotificationDetails(
'payment_failures',
'Échecs de paiement',
channelDescription: 'Notifications d\'échec de paiement',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
color: Color(0xFFF44336),
playSound: true,
enableVibration: true,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _localNotifications.show(
_generateNotificationId(cotisation.id, 'payment_failure'),
'Échec de paiement ❌',
'Le paiement pour la cotisation ${cotisation.typeCotisation} a échoué: $raison',
notificationDetails,
payload: jsonEncode({
'type': 'payment_failure',
'cotisationId': cotisation.id,
'action': 'retry_payment',
}),
);
}
/// Annule toutes les notifications pour une cotisation
Future<void> cancelCotisationNotifications(String cotisationId) async {
final scheduledNotifications = await getScheduledNotifications();
final notificationsToCancel = scheduledNotifications
.where((n) => n['cotisationId'] == cotisationId)
.toList();
for (final notification in notificationsToCancel) {
await _localNotifications.cancel(notification['id'] as int);
}
// Supprimer de la liste des notifications planifiées
final updatedNotifications = scheduledNotifications
.where((n) => n['cotisationId'] != cotisationId)
.toList();
await _prefs.setString(_scheduledNotificationsKey, jsonEncode(updatedNotifications));
}
/// Planifie les notifications pour toutes les cotisations actives
Future<void> scheduleAllCotisationsNotifications(List<CotisationModel> cotisations) async {
// Annuler toutes les notifications existantes
await _localNotifications.cancelAll();
await _clearScheduledNotifications();
// Planifier pour chaque cotisation non payée
for (final cotisation in cotisations) {
if (!cotisation.isEntierementPayee && !cotisation.isEnRetard) {
await schedulePaymentReminder(cotisation);
await scheduleDueDateNotification(cotisation);
}
}
}
/// Configuration des notifications
Future<bool> isNotificationsEnabled() async {
return _prefs.getBool(_notificationsEnabledKey) ?? true;
}
Future<void> setNotificationsEnabled(bool enabled) async {
await _prefs.setBool(_notificationsEnabledKey, enabled);
if (!enabled) {
await _localNotifications.cancelAll();
await _clearScheduledNotifications();
}
}
Future<int> getReminderDays() async {
return _prefs.getInt(_reminderDaysKey) ?? 3; // 3 jours par défaut
}
Future<void> setReminderDays(int days) async {
await _prefs.setInt(_reminderDaysKey, days);
}
Future<List<Map<String, dynamic>>> getScheduledNotifications() async {
final jsonString = _prefs.getString(_scheduledNotificationsKey);
if (jsonString == null) return [];
try {
final List<dynamic> jsonList = jsonDecode(jsonString);
return jsonList.cast<Map<String, dynamic>>();
} catch (e) {
return [];
}
}
/// Méthodes privées
void _onNotificationTapped(NotificationResponse response) {
if (response.payload != null) {
try {
final payload = jsonDecode(response.payload!);
// TODO: Implémenter la navigation selon l'action
// NavigationService.navigateToAction(payload);
} catch (e) {
// Ignorer les erreurs de parsing
}
}
}
int _generateNotificationId(String cotisationId, String type) {
return '${cotisationId}_$type'.hashCode;
}
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
// Note: Cette méthode nécessite le package timezone
// Pour simplifier, on utilise DateTime directement
dynamic _convertToTZDateTime(DateTime dateTime) {
return dateTime; // Simplification - en production, utiliser TZDateTime
}
Future<void> _saveScheduledNotification(
int notificationId,
String cotisationId,
String type,
DateTime scheduledDate,
) async {
final notifications = await getScheduledNotifications();
notifications.add({
'id': notificationId,
'cotisationId': cotisationId,
'type': type,
'scheduledDate': scheduledDate.toIso8601String(),
});
await _prefs.setString(_scheduledNotificationsKey, jsonEncode(notifications));
}
Future<void> _clearScheduledNotifications() async {
await _prefs.remove(_scheduledNotificationsKey);
}
}

View File

@@ -1,233 +0,0 @@
import 'package:injectable/injectable.dart';
import '../models/payment_model.dart';
import 'api_service.dart';
/// Service d'intégration avec Orange Money
/// Gère les paiements via Orange Money pour la Côte d'Ivoire
@LazySingleton()
class OrangeMoneyService {
final ApiService _apiService;
OrangeMoneyService(this._apiService);
/// Initie un paiement Orange Money pour une cotisation
Future<PaymentModel> initiatePayment({
required String cotisationId,
required double montant,
required String numeroTelephone,
String? nomPayeur,
String? emailPayeur,
}) async {
try {
final paymentData = {
'cotisationId': cotisationId,
'montant': montant,
'methodePaiement': 'ORANGE_MONEY',
'numeroTelephone': numeroTelephone,
'nomPayeur': nomPayeur,
'emailPayeur': emailPayeur,
};
// Appel API pour initier le paiement Orange Money
final payment = await _apiService.initiatePayment(paymentData);
return payment;
} catch (e) {
throw OrangeMoneyException('Erreur lors de l\'initiation du paiement Orange Money: ${e.toString()}');
}
}
/// Vérifie le statut d'un paiement Orange Money
Future<PaymentModel> checkPaymentStatus(String paymentId) async {
try {
return await _apiService.getPaymentStatus(paymentId);
} catch (e) {
throw OrangeMoneyException('Erreur lors de la vérification du statut: ${e.toString()}');
}
}
/// Calcule les frais Orange Money selon le barème officiel
double calculateOrangeMoneyFees(double montant) {
// Barème Orange Money Côte d'Ivoire (2024)
if (montant <= 1000) return 0; // Gratuit jusqu'à 1000 XOF
if (montant <= 5000) return 25; // 25 XOF de 1001 à 5000
if (montant <= 10000) return 50; // 50 XOF de 5001 à 10000
if (montant <= 25000) return 100; // 100 XOF de 10001 à 25000
if (montant <= 50000) return 200; // 200 XOF de 25001 à 50000
if (montant <= 100000) return 400; // 400 XOF de 50001 à 100000
if (montant <= 250000) return 750; // 750 XOF de 100001 à 250000
if (montant <= 500000) return 1500; // 1500 XOF de 250001 à 500000
// Au-delà de 500000 XOF: 0.5% du montant
return montant * 0.005;
}
/// Valide un numéro de téléphone Orange Money
bool validatePhoneNumber(String numeroTelephone) {
// Nettoyer le numéro
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
// Orange Money: 07, 08, 09 (Côte d'Ivoire)
// Format: 225XXXXXXXX ou 0XXXXXXXX
return RegExp(r'^(225)?(0[789])\d{8}$').hasMatch(cleanNumber);
}
/// Obtient les limites de transaction Orange Money
Map<String, double> getTransactionLimits() {
return {
'montantMinimum': 100.0, // 100 XOF minimum
'montantMaximum': 1000000.0, // 1 million XOF maximum
'fraisMinimum': 0.0,
'fraisMaximum': 5000.0, // Frais maximum théorique
};
}
/// Vérifie si un montant est dans les limites autorisées
bool isAmountValid(double montant) {
final limits = getTransactionLimits();
return montant >= limits['montantMinimum']! &&
montant <= limits['montantMaximum']!;
}
/// Formate un numéro de téléphone pour Orange Money
String formatPhoneNumber(String numeroTelephone) {
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
// Si le numéro commence par 225, le garder tel quel
if (cleanNumber.startsWith('225')) {
return cleanNumber;
}
// Si le numéro commence par 0, ajouter 225
if (cleanNumber.startsWith('0')) {
return '225$cleanNumber';
}
// Sinon, ajouter 2250
return '2250$cleanNumber';
}
/// Obtient les informations de l'opérateur
Map<String, dynamic> getOperatorInfo() {
return {
'nom': 'Orange Money',
'code': 'ORANGE_MONEY',
'couleur': '#FF6600',
'icone': '📱',
'description': 'Paiement via Orange Money',
'prefixes': ['07', '08', '09'],
'pays': 'Côte d\'Ivoire',
'devise': 'XOF',
};
}
/// Génère un message de confirmation pour l'utilisateur
String generateConfirmationMessage({
required double montant,
required String numeroTelephone,
required double frais,
}) {
final total = montant + frais;
final formattedPhone = formatPhoneNumber(numeroTelephone);
return '''
Confirmation de paiement Orange Money
Montant: ${montant.toStringAsFixed(0)} XOF
Frais: ${frais.toStringAsFixed(0)} XOF
Total: ${total.toStringAsFixed(0)} XOF
Numéro: $formattedPhone
Vous allez recevoir un SMS avec le code de confirmation.
Suivez les instructions pour finaliser le paiement.
''';
}
/// Annule un paiement Orange Money (si possible)
Future<bool> cancelPayment(String paymentId) async {
try {
// Vérifier le statut du paiement
final payment = await checkPaymentStatus(paymentId);
// Un paiement peut être annulé seulement s'il est en attente
if (payment.statut == 'EN_ATTENTE') {
// Appeler l'API d'annulation
await _apiService.cancelPayment(paymentId);
return true;
}
return false;
} catch (e) {
return false;
}
}
/// Obtient l'historique des paiements Orange Money
Future<List<PaymentModel>> getPaymentHistory({
String? cotisationId,
DateTime? dateDebut,
DateTime? dateFin,
int? limit,
}) async {
try {
final filters = <String, dynamic>{
'methodePaiement': 'ORANGE_MONEY',
if (cotisationId != null) 'cotisationId': cotisationId,
if (dateDebut != null) 'dateDebut': dateDebut.toIso8601String(),
if (dateFin != null) 'dateFin': dateFin.toIso8601String(),
if (limit != null) 'limit': limit,
};
return await _apiService.getPaymentHistory(filters);
} catch (e) {
throw OrangeMoneyException('Erreur lors de la récupération de l\'historique: ${e.toString()}');
}
}
/// Vérifie la disponibilité du service Orange Money
Future<bool> checkServiceAvailability() async {
try {
// Appel API pour vérifier la disponibilité
final response = await _apiService.checkServiceStatus('ORANGE_MONEY');
return response['available'] == true;
} catch (e) {
// En cas d'erreur, considérer le service comme indisponible
return false;
}
}
/// Obtient les statistiques des paiements Orange Money
Future<Map<String, dynamic>> getPaymentStatistics({
DateTime? dateDebut,
DateTime? dateFin,
}) async {
try {
final filters = <String, dynamic>{
'methodePaiement': 'ORANGE_MONEY',
if (dateDebut != null) 'dateDebut': dateDebut.toIso8601String(),
if (dateFin != null) 'dateFin': dateFin.toIso8601String(),
};
return await _apiService.getPaymentStatistics(filters);
} catch (e) {
throw OrangeMoneyException('Erreur lors de la récupération des statistiques: ${e.toString()}');
}
}
}
/// Exception personnalisée pour les erreurs Orange Money
class OrangeMoneyException implements Exception {
final String message;
final String? errorCode;
final dynamic originalError;
OrangeMoneyException(
this.message, {
this.errorCode,
this.originalError,
});
@override
String toString() => 'OrangeMoneyException: $message';
}

View File

@@ -1,428 +0,0 @@
import 'package:injectable/injectable.dart';
import '../models/payment_model.dart';
import '../models/cotisation_model.dart';
import 'api_service.dart';
import 'cache_service.dart';
import 'wave_payment_service.dart';
import 'orange_money_service.dart';
import 'moov_money_service.dart';
/// Service de gestion des paiements
/// Gère les transactions de paiement avec différents opérateurs
@LazySingleton()
class PaymentService {
final ApiService _apiService;
final CacheService _cacheService;
final WavePaymentService _waveService;
final OrangeMoneyService _orangeService;
final MoovMoneyService _moovService;
PaymentService(
this._apiService,
this._cacheService,
this._waveService,
this._orangeService,
this._moovService,
);
/// Initie un paiement pour une cotisation
Future<PaymentModel> initiatePayment({
required String cotisationId,
required double montant,
required String methodePaiement,
required String numeroTelephone,
String? nomPayeur,
String? emailPayeur,
}) async {
try {
PaymentModel payment;
// Déléguer au service spécialisé selon la méthode de paiement
switch (methodePaiement) {
case 'WAVE':
payment = await _waveService.initiatePayment(
cotisationId: cotisationId,
montant: montant,
numeroTelephone: numeroTelephone,
nomPayeur: nomPayeur,
emailPayeur: emailPayeur,
);
break;
case 'ORANGE_MONEY':
payment = await _orangeService.initiatePayment(
cotisationId: cotisationId,
montant: montant,
numeroTelephone: numeroTelephone,
nomPayeur: nomPayeur,
emailPayeur: emailPayeur,
);
break;
case 'MOOV_MONEY':
payment = await _moovService.initiatePayment(
cotisationId: cotisationId,
montant: montant,
numeroTelephone: numeroTelephone,
nomPayeur: nomPayeur,
emailPayeur: emailPayeur,
);
break;
default:
throw PaymentException('Méthode de paiement non supportée: $methodePaiement');
}
// Sauvegarder en cache
await _cachePayment(payment);
return payment;
} catch (e) {
if (e is PaymentException) rethrow;
throw PaymentException('Erreur lors de l\'initiation du paiement: ${e.toString()}');
}
}
/// Vérifie le statut d'un paiement
Future<PaymentModel> checkPaymentStatus(String paymentId) async {
try {
// Essayer le cache d'abord
final cachedPayment = await _getCachedPayment(paymentId);
// Si le paiement est déjà terminé (succès ou échec), retourner le cache
if (cachedPayment != null &&
(cachedPayment.isSuccessful || cachedPayment.isFailed)) {
return cachedPayment;
}
// Déterminer le service à utiliser selon la méthode de paiement
PaymentModel payment;
if (cachedPayment != null) {
switch (cachedPayment.methodePaiement) {
case 'WAVE':
payment = await _waveService.checkPaymentStatus(paymentId);
break;
case 'ORANGE_MONEY':
payment = await _orangeService.checkPaymentStatus(paymentId);
break;
case 'MOOV_MONEY':
payment = await _moovService.checkPaymentStatus(paymentId);
break;
default:
throw PaymentException('Méthode de paiement inconnue: ${cachedPayment.methodePaiement}');
}
} else {
// Si pas de cache, essayer tous les services (peu probable)
throw PaymentException('Paiement non trouvé en cache');
}
// Mettre à jour le cache
await _cachePayment(payment);
return payment;
} catch (e) {
// En cas d'erreur réseau, retourner le cache si disponible
final cachedPayment = await _getCachedPayment(paymentId);
if (cachedPayment != null) {
return cachedPayment;
}
throw PaymentException('Erreur lors de la vérification du paiement: ${e.toString()}');
}
}
/// Annule un paiement en cours
Future<bool> cancelPayment(String paymentId) async {
try {
// Récupérer le paiement en cache pour connaître la méthode
final cachedPayment = await _getCachedPayment(paymentId);
if (cachedPayment == null) {
throw PaymentException('Paiement non trouvé');
}
// Déléguer au service approprié
bool cancelled = false;
switch (cachedPayment.methodePaiement) {
case 'WAVE':
cancelled = await _waveService.cancelPayment(paymentId);
break;
case 'ORANGE_MONEY':
cancelled = await _orangeService.cancelPayment(paymentId);
break;
case 'MOOV_MONEY':
cancelled = await _moovService.cancelPayment(paymentId);
break;
default:
throw PaymentException('Méthode de paiement non supportée pour l\'annulation');
}
return cancelled;
} catch (e) {
if (e is PaymentException) rethrow;
throw PaymentException('Erreur lors de l\'annulation du paiement: ${e.toString()}');
}
}
/// Retente un paiement échoué
Future<PaymentModel> retryPayment(String paymentId) async {
try {
// Récupérer le paiement original
final originalPayment = await _getCachedPayment(paymentId);
if (originalPayment == null) {
throw PaymentException('Paiement original non trouvé');
}
// Réinitier le paiement avec les mêmes paramètres
return await initiatePayment(
cotisationId: originalPayment.cotisationId,
montant: originalPayment.montant,
methodePaiement: originalPayment.methodePaiement,
numeroTelephone: originalPayment.numeroTelephone ?? '',
nomPayeur: originalPayment.nomPayeur,
emailPayeur: originalPayment.emailPayeur,
);
} catch (e) {
if (e is PaymentException) rethrow;
throw PaymentException('Erreur lors de la nouvelle tentative de paiement: ${e.toString()}');
}
}
/// Récupère l'historique des paiements d'une cotisation
Future<List<PaymentModel>> getPaymentHistory(String cotisationId) async {
try {
// Essayer le cache d'abord
final cachedPayments = await _cacheService.getPayments();
if (cachedPayments != null) {
final filteredPayments = cachedPayments
.where((p) => p.cotisationId == cotisationId)
.toList();
if (filteredPayments.isNotEmpty) {
return filteredPayments;
}
}
// Si pas de cache, retourner une liste vide
// En production, on pourrait appeler l'API ici
return [];
} catch (e) {
throw PaymentException('Erreur lors de la récupération de l\'historique: ${e.toString()}');
}
}
/// Valide les données de paiement avant envoi
bool validatePaymentData({
required String cotisationId,
required double montant,
required String methodePaiement,
required String numeroTelephone,
}) {
// Validation du montant
if (montant <= 0) return false;
// Validation du numéro de téléphone selon l'opérateur
if (!_validatePhoneNumber(numeroTelephone, methodePaiement)) {
return false;
}
// Validation de la méthode de paiement
if (!_isValidPaymentMethod(methodePaiement)) {
return false;
}
return true;
}
/// Calcule les frais de transaction selon la méthode
double calculateTransactionFees(double montant, String methodePaiement) {
switch (methodePaiement) {
case 'ORANGE_MONEY':
return _calculateOrangeMoneyFees(montant);
case 'WAVE':
return _calculateWaveFees(montant);
case 'MOOV_MONEY':
return _calculateMoovMoneyFees(montant);
case 'CARTE_BANCAIRE':
return _calculateCardFees(montant);
default:
return 0.0;
}
}
/// Retourne les méthodes de paiement disponibles
List<PaymentMethod> getAvailablePaymentMethods() {
return [
PaymentMethod(
id: 'ORANGE_MONEY',
nom: 'Orange Money',
icone: '📱',
couleur: '#FF6600',
description: 'Paiement via Orange Money',
fraisMinimum: 0,
fraisMaximum: 1000,
montantMinimum: 100,
montantMaximum: 1000000,
),
PaymentMethod(
id: 'WAVE',
nom: 'Wave',
icone: '🌊',
couleur: '#00D4FF',
description: 'Paiement via Wave',
fraisMinimum: 0,
fraisMaximum: 500,
montantMinimum: 100,
montantMaximum: 2000000,
),
PaymentMethod(
id: 'MOOV_MONEY',
nom: 'Moov Money',
icone: '💙',
couleur: '#0066CC',
description: 'Paiement via Moov Money',
fraisMinimum: 0,
fraisMaximum: 800,
montantMinimum: 100,
montantMaximum: 1500000,
),
PaymentMethod(
id: 'CARTE_BANCAIRE',
nom: 'Carte bancaire',
icone: '💳',
couleur: '#4CAF50',
description: 'Paiement par carte bancaire',
fraisMinimum: 100,
fraisMaximum: 2000,
montantMinimum: 500,
montantMaximum: 5000000,
),
];
}
/// Méthodes privées
Future<void> _cachePayment(PaymentModel payment) async {
try {
// Utiliser le service de cache pour sauvegarder
final payments = await _cacheService.getPayments() ?? [];
// Remplacer ou ajouter le paiement
final index = payments.indexWhere((p) => p.id == payment.id);
if (index >= 0) {
payments[index] = payment;
} else {
payments.add(payment);
}
await _cacheService.savePayments(payments);
} catch (e) {
// Ignorer les erreurs de cache
}
}
Future<PaymentModel?> _getCachedPayment(String paymentId) async {
try {
final payments = await _cacheService.getPayments();
if (payments != null) {
return payments.firstWhere(
(p) => p.id == paymentId,
orElse: () => throw StateError('Payment not found'),
);
}
return null;
} catch (e) {
return null;
}
}
bool _validatePhoneNumber(String numero, String operateur) {
// Supprimer les espaces et caractères spéciaux
final cleanNumber = numero.replaceAll(RegExp(r'[^\d]'), '');
switch (operateur) {
case 'ORANGE_MONEY':
// Orange: 07, 08, 09 (Côte d'Ivoire)
return RegExp(r'^(225)?(0[789])\d{8}$').hasMatch(cleanNumber);
case 'WAVE':
// Wave accepte tous les numéros ivoiriens
return RegExp(r'^(225)?(0[1-9])\d{8}$').hasMatch(cleanNumber);
case 'MOOV_MONEY':
// Moov: 01, 02, 03
return RegExp(r'^(225)?(0[123])\d{8}$').hasMatch(cleanNumber);
default:
return cleanNumber.length >= 8;
}
}
bool _isValidPaymentMethod(String methode) {
const validMethods = [
'ORANGE_MONEY',
'WAVE',
'MOOV_MONEY',
'CARTE_BANCAIRE',
'VIREMENT',
'ESPECES'
];
return validMethods.contains(methode);
}
double _calculateOrangeMoneyFees(double montant) {
if (montant <= 1000) return 0;
if (montant <= 5000) return 25;
if (montant <= 10000) return 50;
if (montant <= 25000) return 100;
if (montant <= 50000) return 200;
return montant * 0.005; // 0.5%
}
double _calculateWaveFees(double montant) {
// Wave a généralement des frais plus bas
if (montant <= 2000) return 0;
if (montant <= 10000) return 25;
if (montant <= 50000) return 100;
return montant * 0.003; // 0.3%
}
double _calculateMoovMoneyFees(double montant) {
if (montant <= 1000) return 0;
if (montant <= 5000) return 30;
if (montant <= 15000) return 75;
if (montant <= 50000) return 150;
return montant * 0.004; // 0.4%
}
double _calculateCardFees(double montant) {
// Frais fixes + pourcentage pour les cartes
return 100 + (montant * 0.025); // 100 XOF + 2.5%
}
}
/// Modèle pour les méthodes de paiement disponibles
class PaymentMethod {
final String id;
final String nom;
final String icone;
final String couleur;
final String description;
final double fraisMinimum;
final double fraisMaximum;
final double montantMinimum;
final double montantMaximum;
PaymentMethod({
required this.id,
required this.nom,
required this.icone,
required this.couleur,
required this.description,
required this.fraisMinimum,
required this.fraisMaximum,
required this.montantMinimum,
required this.montantMaximum,
});
}
/// Exception personnalisée pour les erreurs de paiement
class PaymentException implements Exception {
final String message;
PaymentException(this.message);
@override
String toString() => 'PaymentException: $message';
}

View File

@@ -1,496 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/payment_model.dart';
import '../models/wave_checkout_session_model.dart';
import 'wave_payment_service.dart';
import 'api_service.dart';
/// Service d'intégration complète Wave Money
/// Gère les paiements, webhooks, et synchronisation
@LazySingleton()
class WaveIntegrationService {
final WavePaymentService _wavePaymentService;
final ApiService _apiService;
final SharedPreferences _prefs;
// Stream controllers pour les événements de paiement
final _paymentStatusController = StreamController<PaymentStatusUpdate>.broadcast();
final _webhookController = StreamController<WaveWebhookData>.broadcast();
WaveIntegrationService(
this._wavePaymentService,
this._apiService,
this._prefs,
);
/// Stream des mises à jour de statut de paiement
Stream<PaymentStatusUpdate> get paymentStatusUpdates => _paymentStatusController.stream;
/// Stream des webhooks Wave
Stream<WaveWebhookData> get webhookUpdates => _webhookController.stream;
/// Initie un paiement Wave complet avec suivi
Future<WavePaymentResult> initiateWavePayment({
required String cotisationId,
required double montant,
required String numeroTelephone,
String? nomPayeur,
String? emailPayeur,
Map<String, dynamic>? metadata,
}) async {
try {
// 1. Créer la session Wave
final session = await _wavePaymentService.createCheckoutSession(
montant: montant,
devise: 'XOF',
successUrl: 'https://unionflow.app/payment/success',
errorUrl: 'https://unionflow.app/payment/error',
typePaiement: 'COTISATION',
description: 'Paiement cotisation $cotisationId',
referenceExterne: cotisationId,
);
// 2. Créer le modèle de paiement
final payment = PaymentModel(
id: session.id ?? session.waveSessionId,
cotisationId: cotisationId,
numeroReference: session.waveSessionId,
montant: montant,
codeDevise: 'XOF',
methodePaiement: 'WAVE',
statut: 'EN_ATTENTE',
dateTransaction: DateTime.now(),
numeroTransaction: session.waveSessionId,
referencePaiement: session.referenceExterne,
operateurMobileMoney: 'WAVE',
numeroTelephone: numeroTelephone,
nomPayeur: nomPayeur,
emailPayeur: emailPayeur,
metadonnees: {
'wave_session_id': session.waveSessionId,
'wave_checkout_url': session.waveUrl,
'cotisation_id': cotisationId,
'numero_telephone': numeroTelephone,
'source': 'unionflow_mobile',
...?metadata,
},
dateCreation: DateTime.now(),
);
// 3. Sauvegarder localement pour suivi
await _savePaymentLocally(payment);
// 4. Démarrer le suivi du paiement
_startPaymentTracking(payment.id, session.waveSessionId);
return WavePaymentResult(
success: true,
payment: payment,
session: session,
checkoutUrl: session.waveUrl,
);
} catch (e) {
return WavePaymentResult(
success: false,
errorMessage: 'Erreur lors de l\'initiation du paiement: ${e.toString()}',
);
}
}
/// Vérifie le statut d'un paiement Wave
Future<PaymentModel?> checkPaymentStatus(String paymentId) async {
try {
// Récupérer depuis le cache local d'abord
final localPayment = await _getLocalPayment(paymentId);
if (localPayment != null && localPayment.isCompleted) {
return localPayment;
}
// Vérifier avec l'API Wave
final sessionId = localPayment?.metadonnees?['wave_session_id'] as String?;
if (sessionId != null) {
final session = await _wavePaymentService.getCheckoutSession(sessionId);
final updatedPayment = await _wavePaymentService.getPaymentStatus(sessionId);
// Mettre à jour le cache local
await _updateLocalPayment(updatedPayment);
// Notifier les listeners
_paymentStatusController.add(PaymentStatusUpdate(
paymentId: paymentId,
status: updatedPayment.statut,
payment: updatedPayment,
));
return updatedPayment;
}
return localPayment;
} catch (e) {
throw WavePaymentException('Erreur lors de la vérification du statut: ${e.toString()}');
}
}
/// Traite un webhook Wave reçu
Future<void> processWaveWebhook(Map<String, dynamic> webhookData) async {
try {
final webhook = WaveWebhookData.fromJson(webhookData);
// Valider la signature du webhook (sécurité)
if (!await _validateWebhookSignature(webhookData)) {
throw WavePaymentException('Signature webhook invalide');
}
// Traiter selon le type d'événement
switch (webhook.eventType) {
case 'payment.completed':
await _handlePaymentCompleted(webhook);
break;
case 'payment.failed':
await _handlePaymentFailed(webhook);
break;
case 'payment.cancelled':
await _handlePaymentCancelled(webhook);
break;
default:
print('Type de webhook non géré: ${webhook.eventType}');
}
// Notifier les listeners
_webhookController.add(webhook);
} catch (e) {
throw WavePaymentException('Erreur lors du traitement du webhook: ${e.toString()}');
}
}
/// Récupère l'historique des paiements Wave
Future<List<PaymentModel>> getWavePaymentHistory({
String? cotisationId,
DateTime? startDate,
DateTime? endDate,
int limit = 50,
}) async {
try {
// Récupérer depuis le cache local
final localPayments = await _getLocalPayments(
cotisationId: cotisationId,
startDate: startDate,
endDate: endDate,
limit: limit,
);
// Synchroniser avec le serveur si nécessaire
if (await _shouldSyncWithServer()) {
final serverPayments = await _apiService.getPaymentHistory(
methodePaiement: 'WAVE',
cotisationId: cotisationId,
startDate: startDate,
endDate: endDate,
limit: limit,
);
// Fusionner et mettre à jour le cache
await _mergeAndCachePayments(serverPayments);
return serverPayments;
}
return localPayments;
} catch (e) {
throw WavePaymentException('Erreur lors de la récupération de l\'historique: ${e.toString()}');
}
}
/// Calcule les statistiques des paiements Wave
Future<WavePaymentStats> getWavePaymentStats({
DateTime? startDate,
DateTime? endDate,
}) async {
try {
final payments = await getWavePaymentHistory(
startDate: startDate,
endDate: endDate,
);
final completedPayments = payments.where((p) => p.isSuccessful).toList();
final failedPayments = payments.where((p) => p.isFailed).toList();
final pendingPayments = payments.where((p) => p.isPending).toList();
final totalAmount = completedPayments.fold<double>(
0.0,
(sum, payment) => sum + payment.montant,
);
final totalFees = completedPayments.fold<double>(
0.0,
(sum, payment) => sum + (payment.fraisTransaction ?? 0.0),
);
return WavePaymentStats(
totalPayments: payments.length,
completedPayments: completedPayments.length,
failedPayments: failedPayments.length,
pendingPayments: pendingPayments.length,
totalAmount: totalAmount,
totalFees: totalFees,
averageAmount: completedPayments.isNotEmpty
? totalAmount / completedPayments.length
: 0.0,
successRate: payments.isNotEmpty
? (completedPayments.length / payments.length) * 100
: 0.0,
);
} catch (e) {
throw WavePaymentException('Erreur lors du calcul des statistiques: ${e.toString()}');
}
}
/// Démarre le suivi d'un paiement
void _startPaymentTracking(String paymentId, String sessionId) {
Timer.periodic(const Duration(seconds: 10), (timer) async {
try {
final payment = await checkPaymentStatus(paymentId);
if (payment != null && (payment.isCompleted || payment.isFailed)) {
timer.cancel();
}
} catch (e) {
print('Erreur lors du suivi du paiement $paymentId: $e');
timer.cancel();
}
});
}
/// Gestion des événements webhook
Future<void> _handlePaymentCompleted(WaveWebhookData webhook) async {
final paymentId = webhook.data['payment_id'] as String?;
if (paymentId != null) {
final payment = await _getLocalPayment(paymentId);
if (payment != null) {
final updatedPayment = payment.copyWith(
statut: 'CONFIRME',
dateModification: DateTime.now(),
);
await _updateLocalPayment(updatedPayment);
}
}
}
Future<void> _handlePaymentFailed(WaveWebhookData webhook) async {
final paymentId = webhook.data['payment_id'] as String?;
if (paymentId != null) {
final payment = await _getLocalPayment(paymentId);
if (payment != null) {
final updatedPayment = payment.copyWith(
statut: 'ECHEC',
messageErreur: webhook.data['error_message'] as String?,
dateModification: DateTime.now(),
);
await _updateLocalPayment(updatedPayment);
}
}
}
Future<void> _handlePaymentCancelled(WaveWebhookData webhook) async {
final paymentId = webhook.data['payment_id'] as String?;
if (paymentId != null) {
final payment = await _getLocalPayment(paymentId);
if (payment != null) {
final updatedPayment = payment.copyWith(
statut: 'ANNULE',
dateModification: DateTime.now(),
);
await _updateLocalPayment(updatedPayment);
}
}
}
/// Méthodes de cache local
Future<void> _savePaymentLocally(PaymentModel payment) async {
final payments = await _getLocalPayments();
payments.add(payment);
await _prefs.setString('wave_payments', jsonEncode(payments.map((p) => p.toJson()).toList()));
}
Future<PaymentModel?> _getLocalPayment(String paymentId) async {
final payments = await _getLocalPayments();
try {
return payments.firstWhere((p) => p.id == paymentId);
} catch (e) {
return null;
}
}
Future<List<PaymentModel>> _getLocalPayments({
String? cotisationId,
DateTime? startDate,
DateTime? endDate,
int? limit,
}) async {
final paymentsJson = _prefs.getString('wave_payments');
if (paymentsJson == null) return [];
final paymentsList = jsonDecode(paymentsJson) as List;
var payments = paymentsList.map((json) => PaymentModel.fromJson(json)).toList();
// Filtrer selon les critères
if (cotisationId != null) {
payments = payments.where((p) => p.cotisationId == cotisationId).toList();
}
if (startDate != null) {
payments = payments.where((p) => p.dateTransaction.isAfter(startDate)).toList();
}
if (endDate != null) {
payments = payments.where((p) => p.dateTransaction.isBefore(endDate)).toList();
}
// Trier par date décroissante
payments.sort((a, b) => b.dateTransaction.compareTo(a.dateTransaction));
// Limiter le nombre de résultats
if (limit != null && payments.length > limit) {
payments = payments.take(limit).toList();
}
return payments;
}
Future<void> _updateLocalPayment(PaymentModel payment) async {
final payments = await _getLocalPayments();
final index = payments.indexWhere((p) => p.id == payment.id);
if (index != -1) {
payments[index] = payment;
await _prefs.setString('wave_payments', jsonEncode(payments.map((p) => p.toJson()).toList()));
}
}
Future<void> _mergeAndCachePayments(List<PaymentModel> serverPayments) async {
final localPayments = await _getLocalPayments();
final mergedPayments = <String, PaymentModel>{};
// Ajouter les paiements locaux
for (final payment in localPayments) {
mergedPayments[payment.id] = payment;
}
// Fusionner avec les paiements du serveur (priorité au serveur)
for (final payment in serverPayments) {
mergedPayments[payment.id] = payment;
}
await _prefs.setString(
'wave_payments',
jsonEncode(mergedPayments.values.map((p) => p.toJson()).toList()),
);
}
Future<bool> _shouldSyncWithServer() async {
final lastSync = _prefs.getInt('last_wave_sync') ?? 0;
final now = DateTime.now().millisecondsSinceEpoch;
const syncInterval = 5 * 60 * 1000; // 5 minutes
return (now - lastSync) > syncInterval;
}
Future<bool> _validateWebhookSignature(Map<String, dynamic> webhookData) async {
// TODO: Implémenter la validation de signature Wave
// Pour l'instant, on retourne true (à sécuriser en production)
return true;
}
void dispose() {
_paymentStatusController.close();
_webhookController.close();
}
}
/// Résultat d'un paiement Wave
class WavePaymentResult {
final bool success;
final PaymentModel? payment;
final WaveCheckoutSessionModel? session;
final String? checkoutUrl;
final String? errorMessage;
WavePaymentResult({
required this.success,
this.payment,
this.session,
this.checkoutUrl,
this.errorMessage,
});
}
/// Mise à jour de statut de paiement
class PaymentStatusUpdate {
final String paymentId;
final String status;
final PaymentModel payment;
PaymentStatusUpdate({
required this.paymentId,
required this.status,
required this.payment,
});
}
/// Données de webhook Wave
class WaveWebhookData {
final String eventType;
final String eventId;
final DateTime timestamp;
final Map<String, dynamic> data;
WaveWebhookData({
required this.eventType,
required this.eventId,
required this.timestamp,
required this.data,
});
factory WaveWebhookData.fromJson(Map<String, dynamic> json) {
return WaveWebhookData(
eventType: json['event_type'] as String,
eventId: json['event_id'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
data: json['data'] as Map<String, dynamic>,
);
}
}
/// Statistiques des paiements Wave
class WavePaymentStats {
final int totalPayments;
final int completedPayments;
final int failedPayments;
final int pendingPayments;
final double totalAmount;
final double totalFees;
final double averageAmount;
final double successRate;
WavePaymentStats({
required this.totalPayments,
required this.completedPayments,
required this.failedPayments,
required this.pendingPayments,
required this.totalAmount,
required this.totalFees,
required this.averageAmount,
required this.successRate,
});
}
/// Exception spécifique aux paiements Wave
class WavePaymentException implements Exception {
final String message;
final String? code;
final dynamic originalError;
WavePaymentException(this.message, {this.code, this.originalError});
@override
String toString() => 'WavePaymentException: $message';
}

View File

@@ -1,229 +0,0 @@
import 'package:injectable/injectable.dart';
import '../models/payment_model.dart';
import '../models/wave_checkout_session_model.dart';
import 'api_service.dart';
/// Service d'intégration avec l'API Wave Money
/// Gère les paiements via Wave Money pour la Côte d'Ivoire
@LazySingleton()
class WavePaymentService {
final ApiService _apiService;
WavePaymentService(this._apiService);
/// Crée une session de checkout Wave via notre API backend
Future<WaveCheckoutSessionModel> createCheckoutSession({
required double montant,
required String devise,
required String successUrl,
required String errorUrl,
String? organisationId,
String? membreId,
String? typePaiement,
String? description,
String? referenceExterne,
}) async {
try {
// Utiliser notre API backend
return await _apiService.createWaveSession(
montant: montant,
devise: devise,
successUrl: successUrl,
errorUrl: errorUrl,
organisationId: organisationId,
membreId: membreId,
typePaiement: typePaiement,
description: description,
);
} catch (e) {
throw WavePaymentException('Erreur lors de la création de la session Wave: ${e.toString()}');
}
}
/// Vérifie le statut d'une session de checkout
Future<WaveCheckoutSessionModel> getCheckoutSession(String sessionId) async {
try {
return await _apiService.getWaveSession(sessionId);
} catch (e) {
throw WavePaymentException('Erreur lors de la récupération de la session: ${e.toString()}');
}
}
/// Initie un paiement Wave pour une cotisation
Future<PaymentModel> initiatePayment({
required String cotisationId,
required double montant,
required String numeroTelephone,
String? nomPayeur,
String? emailPayeur,
}) async {
try {
// Générer les URLs de callback
const successUrl = 'https://unionflow.app/payment/success';
const errorUrl = 'https://unionflow.app/payment/error';
// Créer la session Wave
final session = await createCheckoutSession(
montant: montant,
devise: 'XOF', // Franc CFA
successUrl: successUrl,
errorUrl: errorUrl,
typePaiement: 'COTISATION',
description: 'Paiement cotisation $cotisationId',
referenceExterne: cotisationId,
);
// Convertir en PaymentModel pour l'uniformité
return PaymentModel(
id: session.id ?? session.waveSessionId,
cotisationId: cotisationId,
numeroReference: session.waveSessionId,
montant: montant,
codeDevise: 'XOF',
methodePaiement: 'WAVE',
statut: _mapWaveStatusToPaymentStatus(session.statut),
dateTransaction: DateTime.now(),
numeroTransaction: session.waveSessionId,
referencePaiement: session.referenceExterne,
operateurMobileMoney: 'WAVE',
numeroTelephone: numeroTelephone,
nomPayeur: nomPayeur,
emailPayeur: emailPayeur,
metadonnees: {
'wave_session_id': session.waveSessionId,
'wave_checkout_url': session.waveUrl,
'wave_status': session.statut,
'cotisation_id': cotisationId,
'numero_telephone': numeroTelephone,
'source': 'unionflow_mobile',
},
dateCreation: DateTime.now(),
);
} catch (e) {
if (e is WavePaymentException) {
rethrow;
}
throw WavePaymentException('Erreur lors de l\'initiation du paiement Wave: ${e.toString()}');
}
}
/// Vérifie le statut d'un paiement Wave
Future<PaymentModel> checkPaymentStatus(String paymentId) async {
try {
final session = await getCheckoutSession(paymentId);
return PaymentModel(
id: session.id ?? session.waveSessionId,
cotisationId: session.referenceExterne ?? '',
numeroReference: session.waveSessionId,
montant: session.montant,
codeDevise: session.devise,
methodePaiement: 'WAVE',
statut: _mapWaveStatusToPaymentStatus(session.statut),
dateTransaction: session.dateModification ?? DateTime.now(),
numeroTransaction: session.waveSessionId,
referencePaiement: session.referenceExterne,
operateurMobileMoney: 'WAVE',
metadonnees: {
'wave_session_id': session.waveSessionId,
'wave_checkout_url': session.waveUrl,
'wave_status': session.statut,
'organisation_id': session.organisationId,
'membre_id': session.membreId,
'type_paiement': session.typePaiement,
},
dateCreation: session.dateCreation,
dateModification: session.dateModification,
);
} catch (e) {
if (e is WavePaymentException) {
rethrow;
}
throw WavePaymentException('Erreur lors de la vérification du statut: ${e.toString()}');
}
}
/// Calcule les frais Wave selon le barème officiel
double calculateWaveFees(double montant) {
// Barème Wave Côte d'Ivoire (2024)
if (montant <= 2000) return 0; // Gratuit jusqu'à 2000 XOF
if (montant <= 10000) return 25; // 25 XOF de 2001 à 10000
if (montant <= 50000) return 100; // 100 XOF de 10001 à 50000
if (montant <= 100000) return 200; // 200 XOF de 50001 à 100000
if (montant <= 500000) return 500; // 500 XOF de 100001 à 500000
// Au-delà de 500000 XOF: 0.1% du montant
return montant * 0.001;
}
/// Valide un numéro de téléphone pour Wave
bool validatePhoneNumber(String numeroTelephone) {
// Nettoyer le numéro
final cleanNumber = numeroTelephone.replaceAll(RegExp(r'[^\d]'), '');
// Wave accepte tous les numéros ivoiriens
// Format: 225XXXXXXXX ou 0XXXXXXXX
return RegExp(r'^(225)?(0[1-9])\d{8}$').hasMatch(cleanNumber) ||
RegExp(r'^[1-9]\d{7}$').hasMatch(cleanNumber); // Format court
}
/// Obtient l'URL de checkout pour redirection
String getCheckoutUrl(String sessionId) {
return 'https://checkout.wave.com/checkout/$sessionId';
}
/// Annule une session de paiement (si possible)
Future<bool> cancelPayment(String sessionId) async {
try {
// Vérifier le statut de la session
final session = await getCheckoutSession(sessionId);
// Une session peut être considérée comme annulée si elle a expiré
return session.statut.toLowerCase() == 'expired' ||
session.statut.toLowerCase() == 'cancelled' ||
session.estExpiree;
} catch (e) {
return false;
}
}
/// Méthodes utilitaires privées
String _mapWaveStatusToPaymentStatus(String waveStatus) {
switch (waveStatus.toLowerCase()) {
case 'pending':
case 'en_attente':
return 'EN_ATTENTE';
case 'successful':
case 'completed':
case 'success':
case 'reussie':
return 'REUSSIE';
case 'failed':
case 'echec':
return 'ECHOUEE';
case 'expired':
case 'cancelled':
case 'annulee':
return 'ANNULEE';
default:
return 'EN_ATTENTE';
}
}
}
/// Exception personnalisée pour les erreurs Wave
class WavePaymentException implements Exception {
final String message;
final String? errorCode;
final dynamic originalError;
WavePaymentException(
this.message, {
this.errorCode,
this.originalError,
});
@override
String toString() => 'WavePaymentException: $message';
}

View File

@@ -1,111 +0,0 @@
import 'package:flutter/material.dart';
/// Utilitaires pour rendre l'app responsive
class ResponsiveUtils {
static late MediaQueryData _mediaQueryData;
static late double screenWidth;
static late double screenHeight;
static late double blockSizeHorizontal;
static late double blockSizeVertical;
static late double safeAreaHorizontal;
static late double safeAreaVertical;
static late double safeBlockHorizontal;
static late double safeBlockVertical;
static late double textScaleFactor;
static void init(BuildContext context) {
_mediaQueryData = MediaQuery.of(context);
screenWidth = _mediaQueryData.size.width;
screenHeight = _mediaQueryData.size.height;
blockSizeHorizontal = screenWidth / 100;
blockSizeVertical = screenHeight / 100;
final safeAreaPadding = _mediaQueryData.padding;
safeAreaHorizontal = screenWidth - safeAreaPadding.left - safeAreaPadding.right;
safeAreaVertical = screenHeight - safeAreaPadding.top - safeAreaPadding.bottom;
safeBlockHorizontal = safeAreaHorizontal / 100;
safeBlockVertical = safeAreaVertical / 100;
textScaleFactor = _mediaQueryData.textScaleFactor;
}
// Responsive width
static double wp(double percentage) => blockSizeHorizontal * percentage;
// Responsive height
static double hp(double percentage) => blockSizeVertical * percentage;
// Responsive font size (basé sur la largeur)
static double fs(double percentage) => safeBlockHorizontal * percentage;
// Responsive spacing
static double sp(double percentage) => safeBlockHorizontal * percentage;
// Responsive padding/margin
static EdgeInsets paddingAll(double percentage) =>
EdgeInsets.all(sp(percentage));
static EdgeInsets paddingSymmetric({double? horizontal, double? vertical}) =>
EdgeInsets.symmetric(
horizontal: horizontal != null ? sp(horizontal) : 0,
vertical: vertical != null ? hp(vertical) : 0,
);
static EdgeInsets paddingOnly({
double? left,
double? top,
double? right,
double? bottom,
}) =>
EdgeInsets.only(
left: left != null ? sp(left) : 0,
top: top != null ? hp(top) : 0,
right: right != null ? sp(right) : 0,
bottom: bottom != null ? hp(bottom) : 0,
);
// Adaptive values based on screen size
static double adaptive({
required double small, // < 600px (phones)
required double medium, // 600-900px (tablets)
required double large, // > 900px (desktop)
}) {
if (screenWidth < 600) return small;
if (screenWidth < 900) return medium;
return large;
}
// Check device type
static bool get isMobile => screenWidth < 600;
static bool get isTablet => screenWidth >= 600 && screenWidth < 900;
static bool get isDesktop => screenWidth >= 900;
// Responsive border radius
static BorderRadius borderRadius(double percentage) =>
BorderRadius.circular(sp(percentage));
// Responsive icon size
static double iconSize(double percentage) =>
adaptive(
small: sp(percentage),
medium: sp(percentage * 0.9),
large: sp(percentage * 0.8),
);
}
// Extension pour faciliter l'utilisation
extension ResponsiveExtension on num {
// Width percentage
double get wp => ResponsiveUtils.wp(toDouble());
// Height percentage
double get hp => ResponsiveUtils.hp(toDouble());
// Font size
double get fs => ResponsiveUtils.fs(toDouble());
// Spacing
double get sp => ResponsiveUtils.sp(toDouble());
}

View File

@@ -1,353 +0,0 @@
import 'package:flutter/material.dart';
/// Service de validation des formulaires avec règles métier
class FormValidator {
/// Valide un champ requis
static String? required(String? value, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return '${fieldName ?? 'Ce champ'} est requis';
}
return null;
}
/// Valide un email
static String? email(String? value, {bool required = true}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'L\'email est requis';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value.trim())) {
return 'Format d\'email invalide';
}
return null;
}
/// Valide un numéro de téléphone
static String? phone(String? value, {bool required = true}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'Le numéro de téléphone est requis';
}
// Supprimer tous les espaces et caractères spéciaux sauf + et chiffres
final cleanPhone = value.replaceAll(RegExp(r'[^\d+]'), '');
// Vérifier le format international (+225XXXXXXXX) ou local (XXXXXXXX)
final phoneRegex = RegExp(r'^(\+225)?[0-9]{8,10}$');
if (!phoneRegex.hasMatch(cleanPhone)) {
return 'Format de téléphone invalide (ex: +225XXXXXXXX)';
}
return null;
}
/// Valide la longueur minimale
static String? minLength(String? value, int minLength, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return null; // Laisse la validation required s'en occuper
}
if (value.trim().length < minLength) {
return '${fieldName ?? 'Ce champ'} doit contenir au moins $minLength caractères';
}
return null;
}
/// Valide la longueur maximale
static String? maxLength(String? value, int maxLength, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return null;
}
if (value.trim().length > maxLength) {
return '${fieldName ?? 'Ce champ'} ne peut pas dépasser $maxLength caractères';
}
return null;
}
/// Valide un nom (prénom ou nom de famille)
static String? name(String? value, {String? fieldName, bool required = true}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
final requiredError = FormValidator.required(value, fieldName: fieldName);
if (requiredError != null) return requiredError;
final minLengthError = minLength(value, 2, fieldName: fieldName);
if (minLengthError != null) return minLengthError;
final maxLengthError = maxLength(value, 50, fieldName: fieldName);
if (maxLengthError != null) return maxLengthError;
// Vérifier que le nom ne contient que des lettres, espaces, tirets et apostrophes
final nameRegex = RegExp(r'^[a-zA-ZÀ-ÿ\s\-\u0027]+$');
if (!nameRegex.hasMatch(value!.trim())) {
return '${fieldName ?? 'Ce champ'} ne peut contenir que des lettres';
}
return null;
}
/// Valide une date de naissance
static String? birthDate(DateTime? value, {int minAge = 0, int maxAge = 120}) {
if (value == null) {
return 'La date de naissance est requise';
}
final now = DateTime.now();
final age = now.year - value.year;
if (value.isAfter(now)) {
return 'La date de naissance ne peut pas être dans le futur';
}
if (age < minAge) {
return 'L\'âge minimum requis est de $minAge ans';
}
if (age > maxAge) {
return 'L\'âge maximum autorisé est de $maxAge ans';
}
return null;
}
/// Valide un numéro de membre
static String? memberNumber(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Le numéro de membre est requis';
}
// Format: MBR suivi de 3 chiffres minimum
final memberRegex = RegExp(r'^MBR\d{3,}$');
if (!memberRegex.hasMatch(value.trim())) {
return 'Format invalide (ex: MBR001)';
}
return null;
}
/// Valide une adresse
static String? address(String? value, {bool required = false}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
if (required) {
final requiredError = FormValidator.required(value, fieldName: 'L\'adresse');
if (requiredError != null) return requiredError;
}
final maxLengthError = maxLength(value, 200, fieldName: 'L\'adresse');
if (maxLengthError != null) return maxLengthError;
return null;
}
/// Valide une profession
static String? profession(String? value, {bool required = false}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
if (required) {
final requiredError = FormValidator.required(value, fieldName: 'La profession');
if (requiredError != null) return requiredError;
}
final maxLengthError = maxLength(value, 100, fieldName: 'La profession');
if (maxLengthError != null) return maxLengthError;
return null;
}
/// Combine plusieurs validateurs
static String? Function(String?) combine(List<String? Function(String?)> validators) {
return (String? value) {
for (final validator in validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
};
}
/// Valide un formulaire complet et retourne les erreurs
static Map<String, String> validateForm(Map<String, dynamic> data, Map<String, String? Function(dynamic)> rules) {
final errors = <String, String>{};
for (final entry in rules.entries) {
final field = entry.key;
final validator = entry.value;
final value = data[field];
final error = validator(value);
if (error != null) {
errors[field] = error;
}
}
return errors;
}
/// Valide les données d'un membre
static Map<String, String> validateMember(Map<String, dynamic> memberData) {
return validateForm(memberData, {
'prenom': (value) => name(value, fieldName: 'Le prénom'),
'nom': (value) => name(value, fieldName: 'Le nom'),
'email': (value) => email(value),
'telephone': (value) => phone(value),
'dateNaissance': (value) => value is DateTime ? birthDate(value, minAge: 16) : 'Date de naissance invalide',
'adresse': (value) => address(value),
'profession': (value) => profession(value),
});
}
}
/// Widget de champ de texte avec validation en temps réel
class ValidatedTextField extends StatefulWidget {
final TextEditingController controller;
final String label;
final String? hintText;
final IconData? prefixIcon;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final List<String? Function(String?)> validators;
final bool obscureText;
final int? maxLines;
final int? maxLength;
final bool enabled;
final VoidCallback? onTap;
final ValueChanged<String>? onChanged;
final bool validateOnChange;
const ValidatedTextField({
super.key,
required this.controller,
required this.label,
this.hintText,
this.prefixIcon,
this.keyboardType,
this.textInputAction,
this.validators = const [],
this.obscureText = false,
this.maxLines = 1,
this.maxLength,
this.enabled = true,
this.onTap,
this.onChanged,
this.validateOnChange = true,
});
@override
State<ValidatedTextField> createState() => _ValidatedTextFieldState();
}
class _ValidatedTextFieldState extends State<ValidatedTextField> {
String? _errorText;
bool _hasBeenTouched = false;
@override
void initState() {
super.initState();
if (widget.validateOnChange) {
widget.controller.addListener(_validateField);
}
}
@override
void dispose() {
if (widget.validateOnChange) {
widget.controller.removeListener(_validateField);
}
super.dispose();
}
void _validateField() {
if (!_hasBeenTouched) return;
final value = widget.controller.text;
String? error;
for (final validator in widget.validators) {
error = validator(value);
if (error != null) break;
}
if (mounted) {
setState(() {
_errorText = error;
});
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: widget.controller,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hintText,
prefixIcon: widget.prefixIcon != null ? Icon(widget.prefixIcon) : null,
errorText: _errorText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.grey),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blue, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.red),
),
),
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
obscureText: widget.obscureText,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
enabled: widget.enabled,
onTap: widget.onTap,
onChanged: (value) {
if (!_hasBeenTouched) {
setState(() {
_hasBeenTouched = true;
});
}
widget.onChanged?.call(value);
if (widget.validateOnChange) {
_validateField();
}
},
validator: (value) {
for (final validator in widget.validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
},
),
],
);
}
}

View File

@@ -0,0 +1,398 @@
/// Widget adaptatif révolutionnaire avec morphing intelligent
/// Transformation dynamique selon le rôle utilisateur avec animations fluides
library adaptive_widget;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../auth/models/user.dart';
import '../auth/models/user_role.dart';
import '../auth/services/permission_engine.dart';
import '../auth/bloc/auth_bloc.dart';
/// Widget adaptatif révolutionnaire qui se transforme selon le rôle utilisateur
///
/// Fonctionnalités :
/// - Morphing intelligent avec animations fluides
/// - Widgets spécifiques par rôle
/// - Vérification de permissions intégrée
/// - Fallback gracieux pour les rôles non supportés
/// - Cache des widgets pour les performances
class AdaptiveWidget extends StatefulWidget {
/// Widgets spécifiques par rôle utilisateur
final Map<UserRole, Widget Function()> roleWidgets;
/// Permissions requises pour afficher le widget
final List<String> requiredPermissions;
/// Widget affiché si les permissions sont insuffisantes
final Widget? fallbackWidget;
/// Widget affiché pendant le chargement
final Widget? loadingWidget;
/// Activer les animations de morphing
final bool enableMorphing;
/// Durée de l'animation de morphing
final Duration morphingDuration;
/// Courbe d'animation
final Curve animationCurve;
/// Contexte organisationnel pour les permissions
final String? organizationId;
/// Activer l'audit trail
final bool auditLog;
/// Constructeur du widget adaptatif
const AdaptiveWidget({
super.key,
required this.roleWidgets,
this.requiredPermissions = const [],
this.fallbackWidget,
this.loadingWidget,
this.enableMorphing = true,
this.morphingDuration = const Duration(milliseconds: 800),
this.animationCurve = Curves.easeInOutCubic,
this.organizationId,
this.auditLog = true,
});
@override
State<AdaptiveWidget> createState() => _AdaptiveWidgetState();
}
class _AdaptiveWidgetState extends State<AdaptiveWidget>
with TickerProviderStateMixin {
/// Cache des widgets construits pour éviter les reconstructions
final Map<UserRole, Widget> _widgetCache = {};
/// Contrôleur d'animation pour le morphing
late AnimationController _morphController;
/// Animation d'opacité
late Animation<double> _opacityAnimation;
/// Animation d'échelle
late Animation<double> _scaleAnimation;
/// Rôle utilisateur précédent pour détecter les changements
UserRole? _previousRole;
@override
void initState() {
super.initState();
_initializeAnimations();
}
@override
void dispose() {
_morphController.dispose();
super.dispose();
}
/// Initialise les animations de morphing
void _initializeAnimations() {
_morphController = AnimationController(
duration: widget.morphingDuration,
vsync: this,
);
_opacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _morphController,
curve: widget.animationCurve,
));
_scaleAnimation = Tween<double>(
begin: 0.95,
end: 1.0,
).animate(CurvedAnimation(
parent: _morphController,
curve: widget.animationCurve,
));
// Démarrer l'animation initiale
_morphController.forward();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
// État de chargement
if (state is AuthLoading) {
return widget.loadingWidget ?? _buildLoadingWidget();
}
// État non authentifié
if (state is! AuthAuthenticated) {
return _buildForRole(UserRole.visitor);
}
final user = state.user;
final currentRole = user.primaryRole;
// Détecter le changement de rôle pour déclencher l'animation
if (_previousRole != null && _previousRole != currentRole && widget.enableMorphing) {
_triggerMorphing();
}
_previousRole = currentRole;
return FutureBuilder<bool>(
future: _checkPermissions(user),
builder: (context, permissionSnapshot) {
if (permissionSnapshot.connectionState == ConnectionState.waiting) {
return widget.loadingWidget ?? _buildLoadingWidget();
}
final hasPermissions = permissionSnapshot.data ?? false;
if (!hasPermissions) {
return widget.fallbackWidget ?? _buildUnauthorizedWidget();
}
return _buildForRole(currentRole);
},
);
},
);
}
/// Construit le widget pour un rôle spécifique
Widget _buildForRole(UserRole role) {
// Vérifier le cache
if (_widgetCache.containsKey(role)) {
return _wrapWithAnimation(_widgetCache[role]!);
}
// Trouver le widget approprié
Widget? widget = _findWidgetForRole(role);
if (widget == null) {
widget = this.widget.fallbackWidget ?? _buildUnsupportedRoleWidget(role);
}
// Mettre en cache
_widgetCache[role] = widget;
return _wrapWithAnimation(widget);
}
/// Trouve le widget approprié pour un rôle
Widget? _findWidgetForRole(UserRole role) {
// Vérification directe
if (widget.roleWidgets.containsKey(role)) {
return widget.roleWidgets[role]!();
}
// Recherche du meilleur match par niveau de rôle
UserRole? bestMatch;
for (final availableRole in widget.roleWidgets.keys) {
if (availableRole.level <= role.level) {
if (bestMatch == null || availableRole.level > bestMatch.level) {
bestMatch = availableRole;
}
}
}
return bestMatch != null ? widget.roleWidgets[bestMatch]!() : null;
}
/// Enveloppe le widget avec les animations
Widget _wrapWithAnimation(Widget child) {
if (!widget.enableMorphing) return child;
return AnimatedBuilder(
animation: _morphController,
builder: (context, _) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: child,
),
);
},
);
}
/// Déclenche l'animation de morphing
void _triggerMorphing() {
_morphController.reset();
_morphController.forward();
// Vider le cache pour forcer la reconstruction
_widgetCache.clear();
}
/// Vérifie les permissions requises
Future<bool> _checkPermissions(User user) async {
if (widget.requiredPermissions.isEmpty) return true;
final results = await PermissionEngine.hasPermissions(
user,
widget.requiredPermissions,
organizationId: widget.organizationId,
auditLog: widget.auditLog,
);
return results.values.every((hasPermission) => hasPermission);
}
/// Widget de chargement par défaut
Widget _buildLoadingWidget() {
return const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
/// Widget non autorisé par défaut
Widget _buildUnauthorizedWidget() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 48,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 8),
Text(
'Accès non autorisé',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).disabledColor,
),
),
const SizedBox(height: 4),
Text(
'Vous n\'avez pas les permissions nécessaires',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).disabledColor,
),
textAlign: TextAlign.center,
),
],
),
);
}
/// Widget pour rôle non supporté
Widget _buildUnsupportedRoleWidget(UserRole role) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_outlined,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 8),
Text(
'Rôle non supporté',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 4),
Text(
'Le rôle ${role.displayName} n\'est pas supporté par ce widget',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
);
}
}
/// Widget sécurisé avec vérification de permissions intégrée
///
/// Version simplifiée d'AdaptiveWidget pour les cas où seules
/// les permissions importent, pas le rôle spécifique
class SecureWidget extends StatelessWidget {
/// Permissions requises pour afficher le widget
final List<String> requiredPermissions;
/// Widget à afficher si autorisé
final Widget child;
/// Widget à afficher si non autorisé
final Widget? unauthorizedWidget;
/// Widget à afficher pendant le chargement
final Widget? loadingWidget;
/// Contexte organisationnel
final String? organizationId;
/// Activer l'audit trail
final bool auditLog;
/// Constructeur du widget sécurisé
const SecureWidget({
super.key,
required this.requiredPermissions,
required this.child,
this.unauthorizedWidget,
this.loadingWidget,
this.organizationId,
this.auditLog = true,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthLoading) {
return loadingWidget ?? const SizedBox.shrink();
}
if (state is! AuthAuthenticated) {
return unauthorizedWidget ?? const SizedBox.shrink();
}
return FutureBuilder<bool>(
future: _checkPermissions(state.user),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return loadingWidget ?? const SizedBox.shrink();
}
final hasPermissions = snapshot.data ?? false;
if (!hasPermissions) {
return unauthorizedWidget ?? const SizedBox.shrink();
}
return child;
},
);
},
);
}
/// Vérifie les permissions requises
Future<bool> _checkPermissions(User user) async {
if (requiredPermissions.isEmpty) return true;
final results = await PermissionEngine.hasPermissions(
user,
requiredPermissions,
organizationId: organizationId,
auditLog: auditLog,
);
return results.values.every((hasPermission) => hasPermission);
}
}