Authentification stable - WIP
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Export all auth models
|
||||
export 'auth_state.dart';
|
||||
export 'login_request.dart';
|
||||
export 'login_response.dart';
|
||||
export 'user_info.dart';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
360
unionflow-mobile-apps/lib/core/auth/models/user.dart
Normal file
360
unionflow-mobile-apps/lib/core/auth/models/user.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
319
unionflow-mobile-apps/lib/core/auth/models/user_role.dart
Normal file
319
unionflow-mobile-apps/lib/core/auth/models/user_role.dart
Normal 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,
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)' : ''}';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
418
unionflow-mobile-apps/lib/core/cache/dashboard_cache_manager.dart
vendored
Normal file
418
unionflow-mobile-apps/lib/core/cache/dashboard_cache_manager.dart
vendored
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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%)';
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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é
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
398
unionflow-mobile-apps/lib/core/widgets/adaptive_widget.dart
Normal file
398
unionflow-mobile-apps/lib/core/widgets/adaptive_widget.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user