Versione OK Pour l'onglet événements.
This commit is contained in:
320
unionflow-mobile-apps/lib/core/animations/animated_button.dart
Normal file
320
unionflow-mobile-apps/lib/core/animations/animated_button.dart
Normal file
@@ -0,0 +1,320 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,72 @@ class PageTransitions {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -209,6 +275,16 @@ extension NavigatorTransitions on NavigatorState {
|
||||
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
|
||||
|
||||
@@ -13,10 +13,10 @@ import 'package:dio/dio.dart';
|
||||
|
||||
@singleton
|
||||
class KeycloakWebViewAuthService {
|
||||
static const String _keycloakBaseUrl = 'http://192.168.1.11:8180';
|
||||
static const String _keycloakBaseUrl = 'http://192.168.1.145:8180';
|
||||
static const String _realm = 'unionflow';
|
||||
static const String _clientId = 'unionflow-mobile';
|
||||
static const String _redirectUrl = 'http://192.168.1.11:8080/auth/callback';
|
||||
static const String _redirectUrl = 'http://192.168.1.145:8080/auth/callback';
|
||||
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
final Dio _dio = Dio();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class AppConstants {
|
||||
// API Configuration
|
||||
static const String baseUrl = 'http://192.168.1.11:8080'; // Backend UnionFlow
|
||||
static const String baseUrl = 'http://192.168.1.145:8080'; // Backend UnionFlow
|
||||
static const String apiVersion = '/api';
|
||||
|
||||
// Timeout
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
// 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;
|
||||
@@ -23,6 +26,18 @@ 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'
|
||||
@@ -62,25 +77,50 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
() => _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.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>()));
|
||||
gh.lazySingleton<_i961.CotisationRepository>(
|
||||
() => _i991.CotisationRepositoryImpl(gh<_i238.ApiService>()));
|
||||
() => _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.factory<_i919.CotisationsBloc>(() => _i919.CotisationsBloc(
|
||||
gh<_i961.CotisationRepository>(),
|
||||
gh<_i132.PaymentService>(),
|
||||
gh<_i421.NotificationService>(),
|
||||
));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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';
|
||||
|
||||
@@ -9,6 +12,16 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
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)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -88,6 +88,12 @@ class CotisationModel {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
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%)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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(),
|
||||
};
|
||||
279
unionflow-mobile-apps/lib/core/models/payment_model.dart
Normal file
279
unionflow-mobile-apps/lib/core/models/payment_model.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
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)';
|
||||
}
|
||||
}
|
||||
64
unionflow-mobile-apps/lib/core/models/payment_model.g.dart
Normal file
64
unionflow-mobile-apps/lib/core/models/payment_model.g.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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(),
|
||||
};
|
||||
@@ -19,7 +19,7 @@ class DioClient {
|
||||
void _configureOptions() {
|
||||
_dio.options = BaseOptions(
|
||||
// URL de base de l'API
|
||||
baseUrl: 'http://192.168.1.11:8080', // Adresse de votre API Quarkus
|
||||
baseUrl: 'http://192.168.1.145:8080', // Adresse de votre API Quarkus
|
||||
|
||||
// Timeouts
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -438,7 +439,7 @@ class ApiService {
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/evenements/a-venir',
|
||||
'/api/evenements/a-venir-public',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'size': size,
|
||||
@@ -640,4 +641,75 @@ class ApiService {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
249
unionflow-mobile-apps/lib/core/services/cache_service.dart
Normal file
249
unionflow-mobile-apps/lib/core/services/cache_service.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
280
unionflow-mobile-apps/lib/core/services/moov_money_service.dart
Normal file
280
unionflow-mobile-apps/lib/core/services/moov_money_service.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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';
|
||||
}
|
||||
428
unionflow-mobile-apps/lib/core/services/payment_service.dart
Normal file
428
unionflow-mobile-apps/lib/core/services/payment_service.dart
Normal file
@@ -0,0 +1,428 @@
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user