Versione OK Pour l'onglet événements.

This commit is contained in:
DahoudG
2025-09-15 20:15:34 +00:00
parent 8a619ee1bf
commit 12d514d866
73 changed files with 11508 additions and 674 deletions

View 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,
}

View File

@@ -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,
}

View File

@@ -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,
),
),
);
},
),
);
}
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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)';
}
}

View File

@@ -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,
};

View File

@@ -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) {

View File

@@ -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%)';
}
}

View File

@@ -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(),
};

View 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)';
}
}

View 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(),
};

View File

@@ -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),

View File

@@ -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');
}
}
}

View 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';
}
}

View 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';
}

View File

@@ -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);
}
}

View File

@@ -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';
}

View 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';
}

View File

@@ -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';
}