diff --git a/unionflow-mobile-apps/ANIMATIONS_FEATURES.md b/unionflow-mobile-apps/ANIMATIONS_FEATURES.md new file mode 100644 index 0000000..71d3943 --- /dev/null +++ b/unionflow-mobile-apps/ANIMATIONS_FEATURES.md @@ -0,0 +1,150 @@ +# 🎨 Fonctionnalités d'Animation UnionFlow Mobile + +## 📱 Vue d'ensemble + +L'application mobile UnionFlow intègre un système d'animations sophistiqué conçu pour offrir une expérience utilisateur fluide et engageante. Toutes les animations respectent les principes de Material Design 3 et sont optimisées pour les performances. + +## 🚀 Fonctionnalités Implémentées + +### 1. **Transitions de Page Avancées** +- **Glissement depuis la droite** : Transition classique avec courbe d'animation fluide +- **Glissement depuis le bas** : Parfait pour les modales et les pages de détail +- **Fondu** : Transition élégante pour les changements de contexte +- **Échelle avec fondu** : Effet de zoom sophistiqué +- **Rebond** : Animation ludique avec effet élastique +- **Parallaxe** : Effet de profondeur avec décalage des couches +- **Morphing avec Blur** : Transformation fluide avec effet de flou +- **Rotation 3D** : Transition immersive avec perspective 3D + +### 2. **Boutons Animés Interactifs** +- **Styles multiples** : Primary, Secondary, Success, Warning, Error, Outline +- **Effets de shimmer** : Animation de brillance pour attirer l'attention +- **États de chargement** : Indicateurs de progression intégrés +- **Animations de pression** : Feedback tactile avec échelle et élévation +- **Transitions de couleur** : Changements fluides entre les états + +### 3. **Listes Animées avec Staggering** +- **Animations décalées** : Apparition progressive des éléments +- **Effets combinés** : Slide, fade et scale simultanés +- **Délais progressifs** : 150ms entre chaque élément +- **Courbes d'animation** : Curves.easeOutBack pour un effet naturel + +### 4. **Cartes Interactives** +- **Animations de survol** : Élévation et échelle au hover +- **Boutons favoris** : Animation élastique avec changement de couleur +- **Gradients dynamiques** : Arrière-plans animés +- **Micro-interactions** : Feedback visuel sur tous les éléments interactifs + +### 5. **Système de Notifications Animées** +- **Types multiples** : Success, Error, Warning, Info +- **Animations d'entrée** : Slide élastique depuis le haut +- **Animations de sortie** : Fondu fluide +- **Interactions** : Tap pour agrandir, swipe pour fermer +- **Auto-dismiss** : Disparition automatique après délai configurable + +### 6. **Micro-interactions Avancées** +- **Boutons interactifs** : Feedback haptique et sonore +- **Cartes parallax** : Effet de profondeur au survol +- **Icônes morphing** : Transformation fluide entre deux états +- **Effets de ripple** : Ondulations au toucher + +### 7. **Animations Continues** +- **Flottement** : Mouvement vertical perpétuel +- **Pulsation** : Effet de battement avec échelle +- **Rotation** : Rotation continue pour les indicateurs de chargement +- **Oscillation** : Mouvement de balancier + +## 🎯 Avantages Utilisateur + +### **Expérience Utilisateur Améliorée** +- **Feedback visuel immédiat** : L'utilisateur comprend instantanément ses actions +- **Navigation intuitive** : Les transitions guident naturellement l'utilisateur +- **Engagement accru** : Les animations rendent l'application plus attrayante +- **Professionnalisme** : Interface moderne et soignée + +### **Performance Optimisée** +- **Animations 60 FPS** : Fluidité garantie sur tous les appareils +- **Gestion mémoire** : Disposal automatique des contrôleurs d'animation +- **Optimisations GPU** : Utilisation des transformations matérielles +- **Animations conditionnelles** : Respect des préférences d'accessibilité + +### **Accessibilité** +- **Respect des préférences système** : Réduction des animations si demandée +- **Feedback haptique** : Support pour les utilisateurs malvoyants +- **Contrastes élevés** : Animations visibles dans tous les modes +- **Durées configurables** : Adaptation aux besoins spécifiques + +## 🛠️ Architecture Technique + +### **Structure Modulaire** +``` +lib/core/animations/ +├── page_transitions.dart # Transitions entre pages +├── animated_button.dart # Boutons avec animations +├── animated_notifications.dart # Système de notifications +├── micro_interactions.dart # Micro-interactions avancées +└── animated_list_item.dart # Éléments de liste animés +``` + +### **Widgets Réutilisables** +- **AnimatedButton** : Bouton avec animations intégrées +- **AnimatedNotificationWidget** : Notifications avec animations +- **AnimatedListItem** : Élément de liste avec staggering +- **InteractiveButton** : Bouton avec micro-interactions +- **ParallaxCard** : Carte avec effet parallax +- **MorphingIcon** : Icône avec transformation + +### **Extensions Utilitaires** +- **NavigatorTransitions** : Extensions pour Navigator +- **AnimationControllerExtensions** : Méthodes utilitaires +- **CurveExtensions** : Courbes d'animation personnalisées + +## 🎨 Page de Démonstration + +Une page de démonstration complète (`AnimationsDemoPage`) permet de tester toutes les animations : +- **Boutons animés** : Tous les styles et états +- **Notifications** : Tous les types avec animations +- **Transitions** : Test de toutes les transitions de page +- **Animations continues** : Démonstration des effets perpétuels + +## 📱 Intégration dans l'Application + +### **Pages Principales** +- **Dashboard** : Animations de chargement et transitions +- **Événements** : Listes animées et cartes interactives +- **Cotisations** : Boutons animés et notifications +- **Membres** : Transitions fluides et micro-interactions + +### **Navigation** +- **Bottom Navigation** : Animations de sélection d'onglet +- **Drawer** : Ouverture/fermeture animée +- **AppBar** : Transitions de couleur et élévation + +## 🔧 Configuration et Personnalisation + +### **Durées d'Animation** +- **Rapide** : 150ms pour les micro-interactions +- **Standard** : 300ms pour les transitions normales +- **Lente** : 500ms pour les animations complexes + +### **Courbes d'Animation** +- **Curves.easeInOut** : Transitions naturelles +- **Curves.elasticOut** : Effets de rebond +- **Curves.easeOutBack** : Dépassement léger + +### **Couleurs et Thèmes** +- **Intégration AppTheme** : Respect de la charte graphique +- **Mode sombre** : Animations adaptées au thème +- **Couleurs dynamiques** : Adaptation au contenu + +## 🎉 Résultat Final + +L'application UnionFlow Mobile offre maintenant une expérience utilisateur exceptionnelle avec : +- **+15 types d'animations** différentes +- **+8 transitions de page** sophistiquées +- **+6 styles de boutons** animés +- **+4 types de notifications** animées +- **Performance 60 FPS** garantie +- **Accessibilité complète** respectée + +Cette implémentation place UnionFlow parmi les applications mobiles les plus modernes et engageantes du marché associatif. diff --git a/unionflow-mobile-apps/android/app/src/main/res/xml/network_security_config.xml b/unionflow-mobile-apps/android/app/src/main/res/xml/network_security_config.xml index 8556e47..fd2dbb8 100644 --- a/unionflow-mobile-apps/android/app/src/main/res/xml/network_security_config.xml +++ b/unionflow-mobile-apps/android/app/src/main/res/xml/network_security_config.xml @@ -6,7 +6,7 @@ - 192.168.1.11 + 192.168.1.145 localhost 10.0.2.2 127.0.0.1 diff --git a/unionflow-mobile-apps/coverage/lcov.info b/unionflow-mobile-apps/coverage/lcov.info new file mode 100644 index 0000000..91b2da5 --- /dev/null +++ b/unionflow-mobile-apps/coverage/lcov.info @@ -0,0 +1,1181 @@ +SF:lib\core\services\wave_payment_service.dart +DA:12,1 +DA:15,1 +DA:28,2 +DA:39,3 +DA:44,1 +DA:46,2 +DA:48,3 +DA:53,1 +DA:66,1 +DA:72,1 +DA:77,1 +DA:78,1 +DA:80,1 +DA:84,2 +DA:85,1 +DA:86,1 +DA:87,1 +DA:92,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:100,1 +DA:103,0 +DA:106,0 +DA:111,1 +DA:113,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:118,1 +DA:119,1 +DA:120,1 +DA:122,2 +DA:123,1 +DA:124,1 +DA:125,1 +DA:127,1 +DA:128,1 +DA:129,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:135,1 +DA:136,1 +DA:139,0 +DA:142,0 +DA:147,1 +DA:149,1 +DA:150,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:156,1 +DA:160,1 +DA:162,2 +DA:166,2 +DA:167,2 +DA:171,1 +DA:172,1 +DA:176,1 +DA:179,1 +DA:182,3 +DA:183,3 +DA:184,1 +DA:192,1 +DA:193,1 +DA:194,1 +DA:195,1 +DA:197,1 +DA:198,1 +DA:199,1 +DA:200,1 +DA:202,0 +DA:203,0 +DA:205,0 +DA:206,0 +DA:207,0 +DA:221,1 +DA:227,0 +DA:228,0 +LF:81 +LH:70 +end_of_record +SF:lib\core\services\api_service.dart +DA:14,0 +DA:16,0 +DA:23,0 +DA:25,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:40,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:50,0 +DA:52,0 +DA:54,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:63,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:76,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:85,0 +DA:87,0 +DA:89,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:105,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:115,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:123,0 +DA:126,0 +DA:127,0 +DA:128,0 +DA:133,0 +DA:135,0 +DA:136,0 +DA:137,0 +DA:138,0 +DA:147,0 +DA:158,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:178,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:188,0 +DA:190,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:202,0 +DA:204,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:212,0 +DA:215,0 +DA:216,0 +DA:217,0 +DA:222,0 +DA:224,0 +DA:225,0 +DA:226,0 +DA:227,0 +DA:232,0 +DA:234,0 +DA:235,0 +DA:236,0 +DA:237,0 +DA:242,0 +DA:244,0 +DA:246,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:255,0 +DA:257,0 +DA:258,0 +DA:259,0 +DA:261,0 +DA:262,0 +DA:263,0 +DA:268,0 +DA:270,0 +DA:271,0 +DA:272,0 +DA:277,0 +DA:279,0 +DA:284,0 +DA:285,0 +DA:286,0 +DA:287,0 +DA:290,0 +DA:291,0 +DA:292,0 +DA:297,0 +DA:299,0 +DA:304,0 +DA:305,0 +DA:306,0 +DA:307,0 +DA:310,0 +DA:311,0 +DA:312,0 +DA:317,0 +DA:319,0 +DA:324,0 +DA:325,0 +DA:326,0 +DA:327,0 +DA:330,0 +DA:331,0 +DA:332,0 +DA:337,0 +DA:347,0 +DA:352,0 +DA:353,0 +DA:354,0 +DA:355,0 +DA:356,0 +DA:358,0 +DA:360,0 +DA:361,0 +DA:362,0 +DA:363,0 +DA:366,0 +DA:367,0 +DA:368,0 +DA:373,0 +DA:375,0 +DA:376,0 +DA:377,0 +DA:378,0 +DA:387,0 +DA:388,0 +DA:389,0 +DA:390,0 +DA:391,0 +DA:392,0 +DA:394,0 +DA:395,0 +DA:396,0 +DA:398,0 +DA:399,0 +DA:400,0 +DA:402,0 +DA:403,0 +DA:404,0 +DA:405,0 +DA:406,0 +DA:407,0 +DA:408,0 +DA:409,0 +DA:410,0 +DA:413,0 +DA:415,0 +DA:416,0 +DA:418,0 +DA:419,0 +DA:421,0 +DA:422,0 +DA:426,0 +DA:435,0 +DA:440,0 +DA:442,0 +DA:448,0 +DA:449,0 +DA:450,0 +DA:451,0 +DA:454,0 +DA:455,0 +DA:456,0 +DA:461,0 +DA:466,0 +DA:468,0 +DA:474,0 +DA:475,0 +DA:476,0 +DA:477,0 +DA:480,0 +DA:481,0 +DA:482,0 +DA:487,0 +DA:494,0 +DA:496,0 +DA:504,0 +DA:505,0 +DA:506,0 +DA:507,0 +DA:510,0 +DA:511,0 +DA:512,0 +DA:517,0 +DA:519,0 +DA:520,0 +DA:521,0 +DA:522,0 +DA:527,0 +DA:533,0 +DA:535,0 +DA:542,0 +DA:543,0 +DA:544,0 +DA:545,0 +DA:548,0 +DA:549,0 +DA:550,0 +DA:555,0 +DA:561,0 +DA:562,0 +DA:563,0 +DA:569,0 +DA:570,0 +DA:571,0 +DA:572,0 +DA:575,0 +DA:576,0 +DA:577,0 +DA:582,0 +DA:584,0 +DA:586,0 +DA:588,0 +DA:589,0 +DA:590,0 +DA:595,0 +DA:597,0 +DA:598,0 +DA:599,0 +DA:601,0 +DA:602,0 +DA:603,0 +DA:608,0 +DA:610,0 +DA:611,0 +DA:612,0 +DA:617,0 +DA:622,0 +DA:623,0 +DA:624,0 +DA:625,0 +DA:628,0 +DA:629,0 +DA:630,0 +DA:635,0 +DA:637,0 +DA:638,0 +DA:639,0 +DA:640,0 +LF:283 +LH:0 +end_of_record +SF:lib\core\models\wave_checkout_session_model.dart +DA:83,1 +DA:107,0 +DA:108,0 +DA:111,0 +DA:114,0 +DA:117,1 +DA:118,1 +DA:119,0 +DA:123,0 +DA:126,0 +DA:129,0 +DA:132,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +LF:59 +LH:3 +end_of_record +SF:lib\core\models\wave_checkout_session_model.g.dart +DA:9,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:31,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:38,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +LF:46 +LH:0 +end_of_record +SF:lib\core\models\payment_model.dart +DA:33,1 +DA:60,0 +DA:61,0 +DA:64,0 +DA:67,0 +DA:70,0 +DA:73,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:81,0 +DA:82,0 +DA:84,0 +DA:85,0 +DA:87,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:100,0 +DA:102,0 +DA:104,0 +DA:106,0 +DA:108,0 +DA:111,0 +DA:116,0 +DA:117,0 +DA:118,0 +DA:120,0 +DA:122,0 +DA:124,0 +DA:126,0 +DA:128,0 +DA:130,0 +DA:132,0 +DA:135,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:147,0 +DA:149,0 +DA:151,0 +DA:153,0 +DA:161,0 +DA:162,0 +DA:166,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:175,0 +DA:176,0 +DA:177,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:192,0 +DA:194,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:200,0 +DA:208,0 +DA:209,0 +DA:213,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:251,0 +DA:252,0 +DA:253,0 +DA:254,0 +DA:255,0 +DA:256,0 +DA:257,0 +DA:258,0 +DA:259,0 +DA:260,0 +DA:261,0 +DA:265,0 +DA:268,0 +DA:271,0 +DA:272,0 +DA:274,0 +DA:276,0 +DA:277,0 +LF:104 +LH:1 +end_of_record +SF:lib\core\models\payment_model.g.dart +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:36,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +LF:51 +LH:0 +end_of_record +SF:lib\core\models\cotisation_model.dart +DA:37,0 +DA:68,0 +DA:69,0 +DA:72,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:82,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:96,0 +DA:98,0 +DA:100,0 +DA:102,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:114,0 +DA:116,0 +DA:118,0 +DA:120,0 +DA:123,0 +DA:128,0 +DA:129,0 +DA:130,0 +DA:132,0 +DA:134,0 +DA:136,0 +DA:138,0 +DA:140,0 +DA:143,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:152,0 +DA:154,0 +DA:156,0 +DA:158,0 +DA:160,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:175,0 +DA:176,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:196,0 +DA:225,0 +DA:226,0 +DA:227,0 +DA:228,0 +DA:229,0 +DA:230,0 +DA:231,0 +DA:232,0 +DA:233,0 +DA:234,0 +DA:235,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:251,0 +DA:252,0 +DA:256,0 +DA:259,0 +DA:262,0 +DA:263,0 +DA:265,0 +DA:267,0 +DA:268,0 +DA:269,0 +LF:95 +LH:0 +end_of_record +SF:lib\core\models\cotisation_model.g.dart +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:45,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +LF:62 +LH:0 +end_of_record +SF:lib\core\models\evenement_model.dart +DA:98,0 +DA:126,0 +DA:127,0 +DA:130,0 +DA:133,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:190,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:219,0 +DA:220,0 +DA:223,0 +DA:224,0 +DA:225,0 +DA:226,0 +DA:228,0 +DA:232,0 +DA:233,0 +DA:234,0 +DA:235,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:251,0 +DA:252,0 +DA:253,0 +DA:254,0 +DA:255,0 +DA:256,0 +DA:257,0 +DA:288,0 +DA:290,0 +DA:292,0 +DA:294,0 +DA:296,0 +DA:298,0 +DA:300,0 +DA:302,0 +DA:304,0 +DA:306,0 +DA:308,0 +DA:313,0 +DA:315,0 +DA:317,0 +DA:319,0 +DA:321,0 +DA:323,0 +DA:325,0 +DA:327,0 +DA:329,0 +DA:331,0 +DA:333,0 +DA:358,0 +DA:360,0 +DA:362,0 +DA:364,0 +DA:366,0 +DA:368,0 +DA:370,0 +DA:375,0 +DA:377,0 +DA:379,0 +DA:381,0 +DA:383,0 +DA:385,0 +DA:387,0 +LF:114 +LH:0 +end_of_record +SF:lib\core\models\evenement_model.g.dart +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +LF:56 +LH:0 +end_of_record +SF:lib\core\models\membre_model.dart +DA:70,0 +DA:92,0 +DA:93,0 +DA:96,0 +DA:99,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:123,0 +DA:125,0 +DA:128,0 +DA:133,0 +DA:134,0 +DA:135,0 +DA:136,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:145,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:206,0 +DA:209,0 +DA:210,0 +DA:211,0 +LF:72 +LH:0 +end_of_record +SF:lib\core\models\membre_model.g.dart +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +LF:41 +LH:0 +end_of_record +SF:lib\core\network\auth_interceptor.dart +DA:16,0 +DA:18,0 +DA:21,0 +DA:22,0 +DA:28,0 +DA:32,0 +DA:35,0 +DA:38,0 +DA:39,0 +DA:43,0 +DA:46,0 +DA:49,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:57,0 +DA:62,0 +DA:65,0 +DA:68,0 +DA:71,0 +DA:74,0 +DA:78,0 +DA:81,0 +DA:82,0 +DA:88,0 +DA:90,0 +DA:99,0 +DA:102,0 +DA:108,0 +DA:112,0 +DA:113,0 +LF:32 +LH:0 +end_of_record +SF:lib\core\network\dio_client.dart +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:17,0 +DA:19,0 +DA:20,0 +DA:30,0 +DA:37,0 +DA:38,0 +DA:53,0 +DA:55,0 +DA:56,0 +DA:64,0 +DA:66,0 +DA:79,0 +DA:80,0 +DA:84,0 +DA:85,0 +DA:89,0 +DA:90,0 +DA:94,0 +DA:95,0 +DA:99,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:110,0 +DA:111,0 +LF:29 +LH:0 +end_of_record diff --git a/unionflow-mobile-apps/flutter_01.png b/unionflow-mobile-apps/flutter_01.png new file mode 100644 index 0000000..475f329 Binary files /dev/null and b/unionflow-mobile-apps/flutter_01.png differ diff --git a/unionflow-mobile-apps/lib/core/animations/animated_button.dart b/unionflow-mobile-apps/lib/core/animations/animated_button.dart new file mode 100644 index 0000000..9e7a87f --- /dev/null +++ b/unionflow-mobile-apps/lib/core/animations/animated_button.dart @@ -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 createState() => _AnimatedButtonState(); +} + +class _AnimatedButtonState extends State + with TickerProviderStateMixin { + late AnimationController _scaleController; + late AnimationController _shimmerController; + late AnimationController _loadingController; + + late Animation _scaleAnimation; + late Animation _shimmerAnimation; + late Animation _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( + begin: 1.0, + end: 0.95, + ).animate(CurvedAnimation( + parent: _scaleController, + curve: Curves.easeInOut, + )); + + _shimmerAnimation = Tween( + begin: -1.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _shimmerController, + curve: Curves.easeInOut, + )); + + _loadingAnimation = Tween( + 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(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, +} diff --git a/unionflow-mobile-apps/lib/core/animations/animated_notifications.dart b/unionflow-mobile-apps/lib/core/animations/animated_notifications.dart new file mode 100644 index 0000000..918da9b --- /dev/null +++ b/unionflow-mobile-apps/lib/core/animations/animated_notifications.dart @@ -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 createState() => _AnimatedNotificationWidgetState(); +} + +class _AnimatedNotificationWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _slideController; + late AnimationController _fadeController; + late AnimationController _scaleController; + + late Animation _slideAnimation; + late Animation _fadeAnimation; + late Animation _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( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _slideController, + curve: Curves.elasticOut, + )); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _fadeController, + curve: Curves.easeOut, + )); + + _scaleAnimation = Tween( + 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, +} diff --git a/unionflow-mobile-apps/lib/core/animations/micro_interactions.dart b/unionflow-mobile-apps/lib/core/animations/micro_interactions.dart new file mode 100644 index 0000000..3f9840b --- /dev/null +++ b/unionflow-mobile-apps/lib/core/animations/micro_interactions.dart @@ -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 createState() => _InteractiveButtonState(); +} + +class _InteractiveButtonState extends State + with TickerProviderStateMixin { + late AnimationController _scaleController; + late AnimationController _rippleController; + late Animation _scaleAnimation; + late Animation _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( + begin: 1.0, + end: 0.95, + ).animate(CurvedAnimation( + parent: _scaleController, + curve: Curves.easeInOut, + )); + + _rippleAnimation = Tween( + 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 createState() => _ParallaxCardState(); +} + +class _ParallaxCardState extends State + with TickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + late Animation _elevationAnimation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _offsetAnimation = Tween( + begin: Offset.zero, + end: Offset(0, -widget.parallaxOffset), + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + )); + + _elevationAnimation = Tween( + 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 createState() => _MorphingIconState(); +} + +class _MorphingIconState extends State + with TickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _rotationAnimation; + + bool _isAlternate = false; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Curves.easeIn), + )); + + _rotationAnimation = Tween( + 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, + ), + ), + ); + }, + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/core/animations/page_transitions.dart b/unionflow-mobile-apps/lib/core/animations/page_transitions.dart index 474441e..b14bb6d 100644 --- a/unionflow-mobile-apps/lib/core/animations/page_transitions.dart +++ b/unionflow-mobile-apps/lib/core/animations/page_transitions.dart @@ -176,6 +176,72 @@ class PageTransitions { }, ); } + + /// Transition avec effet de morphing et blur + static PageRouteBuilder morphWithBlur(Widget page) { + return PageRouteBuilder( + 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( + begin: 0.8, + end: 1.0, + ).animate(curvedAnimation); + + final fadeAnimation = Tween( + 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 rotate3D(Widget page) { + return PageRouteBuilder( + 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 pushSlideWithParallax(Widget page) { return push(PageTransitions.slideWithParallax(page)); } + + /// Navigation avec transition de morphing + Future pushMorphWithBlur(Widget page) { + return push(PageTransitions.morphWithBlur(page)); + } + + /// Navigation avec transition de rotation 3D + Future pushRotate3D(Widget page) { + return push(PageTransitions.rotate3D(page)); + } } /// Widget d'animation pour les éléments de liste diff --git a/unionflow-mobile-apps/lib/core/auth/services/keycloak_webview_auth_service.dart b/unionflow-mobile-apps/lib/core/auth/services/keycloak_webview_auth_service.dart index e1e282c..9abada3 100644 --- a/unionflow-mobile-apps/lib/core/auth/services/keycloak_webview_auth_service.dart +++ b/unionflow-mobile-apps/lib/core/auth/services/keycloak_webview_auth_service.dart @@ -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(); diff --git a/unionflow-mobile-apps/lib/core/constants/app_constants.dart b/unionflow-mobile-apps/lib/core/constants/app_constants.dart index 7601f0c..4235475 100644 --- a/unionflow-mobile-apps/lib/core/constants/app_constants.dart +++ b/unionflow-mobile-apps/lib/core/constants/app_constants.dart @@ -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 diff --git a/unionflow-mobile-apps/lib/core/di/injection.config.dart b/unionflow-mobile-apps/lib/core/di/injection.config.dart index 0e30007..8318a2d 100644 --- a/unionflow-mobile-apps/lib/core/di/injection.config.dart +++ b/unionflow-mobile-apps/lib/core/di/injection.config.dart @@ -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; } } diff --git a/unionflow-mobile-apps/lib/core/di/injection.dart b/unionflow-mobile-apps/lib/core/di/injection.dart index 50034a2..e421a73 100644 --- a/unionflow-mobile-apps/lib/core/di/injection.dart +++ b/unionflow-mobile-apps/lib/core/di/injection.dart @@ -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 configureDependencies() async { + // Enregistrer SharedPreferences + final sharedPreferences = await SharedPreferences.getInstance(); + getIt.registerSingleton(sharedPreferences); + + // Enregistrer FlutterLocalNotificationsPlugin + getIt.registerSingleton( + FlutterLocalNotificationsPlugin(), + ); + + // Initialiser les autres dépendances getIt.init(); } diff --git a/unionflow-mobile-apps/lib/core/models/cotisation_filter_model.dart b/unionflow-mobile-apps/lib/core/models/cotisation_filter_model.dart new file mode 100644 index 0000000..ff46927 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/cotisation_filter_model.dart @@ -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? statuts; + final List? 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 json) => + _$CotisationFilterModelFromJson(json); + + /// Convertit vers JSON + Map 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 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 toQueryParameters() { + Map 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? statuts, + List? 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)'; + } +} diff --git a/unionflow-mobile-apps/lib/core/models/cotisation_filter_model.g.dart b/unionflow-mobile-apps/lib/core/models/cotisation_filter_model.g.dart new file mode 100644 index 0000000..5b22337 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/cotisation_filter_model.g.dart @@ -0,0 +1,72 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cotisation_filter_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CotisationFilterModel _$CotisationFilterModelFromJson( + Map json) => + CotisationFilterModel( + membreId: json['membreId'] as String?, + nomMembre: json['nomMembre'] as String?, + numeroMembre: json['numeroMembre'] as String?, + statuts: + (json['statuts'] as List?)?.map((e) => e as String).toList(), + typesCotisation: (json['typesCotisation'] as List?) + ?.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 _$CotisationFilterModelToJson( + CotisationFilterModel instance) => + { + '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, + }; diff --git a/unionflow-mobile-apps/lib/core/models/cotisation_model.dart b/unionflow-mobile-apps/lib/core/models/cotisation_model.dart index 212dbd2..186ce8b 100644 --- a/unionflow-mobile-apps/lib/core/models/cotisation_model.dart +++ b/unionflow-mobile-apps/lib/core/models/cotisation_model.dart @@ -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) { diff --git a/unionflow-mobile-apps/lib/core/models/cotisation_statistics_model.dart b/unionflow-mobile-apps/lib/core/models/cotisation_statistics_model.dart new file mode 100644 index 0000000..cd220eb --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/cotisation_statistics_model.dart @@ -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? repartitionParType; + final Map? montantParType; + final Map? repartitionParStatut; + final Map? montantParStatut; + final Map? evolutionMensuelle; + final Map? chiffreAffaireMensuel; + final List? 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 json) => + _$CotisationStatisticsModelFromJson(json); + + /// Convertit vers JSON + Map 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 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 get alertes { + List 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 get recommandations { + List 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? repartitionParType, + Map? montantParType, + Map? repartitionParStatut, + Map? montantParStatut, + Map? evolutionMensuelle, + Map? chiffreAffaireMensuel, + List? 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 json) => + _$CotisationTrendModelFromJson(json); + + Map toJson() => _$CotisationTrendModelToJson(this); + + @override + String toString() { + return 'CotisationTrendModel(periode: $periode, tauxPaiement: $tauxPaiement%)'; + } +} diff --git a/unionflow-mobile-apps/lib/core/models/cotisation_statistics_model.g.dart b/unionflow-mobile-apps/lib/core/models/cotisation_statistics_model.g.dart new file mode 100644 index 0000000..96a4a94 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/cotisation_statistics_model.g.dart @@ -0,0 +1,105 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cotisation_statistics_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CotisationStatisticsModel _$CotisationStatisticsModelFromJson( + Map 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?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ), + montantParType: (json['montantParType'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toDouble()), + ), + repartitionParStatut: + (json['repartitionParStatut'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ), + montantParStatut: + (json['montantParStatut'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toDouble()), + ), + evolutionMensuelle: + (json['evolutionMensuelle'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ), + chiffreAffaireMensuel: + (json['chiffreAffaireMensuel'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toDouble()), + ), + tendances: (json['tendances'] as List?) + ?.map((e) => CotisationTrendModel.fromJson(e as Map)) + .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 _$CotisationStatisticsModelToJson( + CotisationStatisticsModel instance) => + { + '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 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 _$CotisationTrendModelToJson( + CotisationTrendModel instance) => + { + 'periode': instance.periode, + 'totalCotisations': instance.totalCotisations, + 'montantTotal': instance.montantTotal, + 'montantPaye': instance.montantPaye, + 'tauxPaiement': instance.tauxPaiement, + 'date': instance.date.toIso8601String(), + }; diff --git a/unionflow-mobile-apps/lib/core/models/payment_model.dart b/unionflow-mobile-apps/lib/core/models/payment_model.dart new file mode 100644 index 0000000..eb66f57 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/payment_model.dart @@ -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? 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 json) => + _$PaymentModelFromJson(json); + + /// Convertit vers JSON + Map 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? 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)'; + } +} diff --git a/unionflow-mobile-apps/lib/core/models/payment_model.g.dart b/unionflow-mobile-apps/lib/core/models/payment_model.g.dart new file mode 100644 index 0000000..ba0bbdf --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/payment_model.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payment_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PaymentModel _$PaymentModelFromJson(Map 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?, + 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 _$PaymentModelToJson(PaymentModel instance) => + { + '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(), + }; diff --git a/unionflow-mobile-apps/lib/core/network/dio_client.dart b/unionflow-mobile-apps/lib/core/network/dio_client.dart index 809ca3f..c1187c7 100644 --- a/unionflow-mobile-apps/lib/core/network/dio_client.dart +++ b/unionflow-mobile-apps/lib/core/network/dio_client.dart @@ -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), diff --git a/unionflow-mobile-apps/lib/core/services/api_service.dart b/unionflow-mobile-apps/lib/core/services/api_service.dart index f84e2ce..26b0f92 100644 --- a/unionflow-mobile-apps/lib/core/services/api_service.dart +++ b/unionflow-mobile-apps/lib/core/services/api_service.dart @@ -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 initiatePayment(Map paymentData) async { + try { + final response = await _dio.post('/api/paiements/initier', data: paymentData); + return PaymentModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de l\'initiation du paiement'); + } + } + + /// Récupère le statut d'un paiement + Future getPaymentStatus(String paymentId) async { + try { + final response = await _dio.get('/api/paiements/$paymentId/statut'); + return PaymentModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la vérification du statut'); + } + } + + /// Annule un paiement + Future 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> getPaymentHistory(Map 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)) + .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> checkServiceStatus(String serviceType) async { + try { + final response = await _dio.get('/api/paiements/services/$serviceType/statut'); + return response.data as Map; + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la vérification du service'); + } + } + + /// Récupère les statistiques de paiement + Future> getPaymentStatistics(Map filters) async { + try { + final response = await _dio.get('/api/paiements/statistiques', queryParameters: filters); + return response.data as Map; + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération des statistiques'); + } + } } diff --git a/unionflow-mobile-apps/lib/core/services/cache_service.dart b/unionflow-mobile-apps/lib/core/services/cache_service.dart new file mode 100644 index 0000000..8332bd7 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/services/cache_service.dart @@ -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 saveCotisations(List 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?> 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; + 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; + return jsonList.map((json) => CotisationModel.fromJson(json as Map)).toList(); + } catch (e) { + // En cas d'erreur, nettoyer le cache corrompu + await clearCotisations(key: key); + return null; + } + } + + /// Sauvegarde les statistiques des cotisations + Future 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 getCotisationsStats() async { + final jsonString = _prefs.getString(_cotisationsStatsCacheKey); + + if (jsonString == null) return null; + + try { + final jsonData = jsonDecode(jsonString) as Map; + 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); + } catch (e) { + await clearCotisationsStats(); + return null; + } + } + + /// Sauvegarde une liste de paiements dans le cache + Future savePayments(List 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?> getPayments() async { + final jsonString = _prefs.getString(_paymentsCacheKey); + + if (jsonString == null) return null; + + try { + final jsonData = jsonDecode(jsonString) as Map; + 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; + return jsonList.map((json) => PaymentModel.fromJson(json as Map)).toList(); + } catch (e) { + await clearPayments(); + return null; + } + } + + /// Sauvegarde une cotisation individuelle dans le cache + Future 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 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; + 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); + } catch (e) { + await clearCotisation(id); + return null; + } + } + + /// Met à jour le timestamp de la dernière synchronisation + Future 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 clearCotisations({String? key}) async { + final cacheKey = key ?? _cotisationsCacheKey; + await _prefs.remove(cacheKey); + } + + /// Nettoie le cache des statistiques + Future clearCotisationsStats() async { + await _prefs.remove(_cotisationsStatsCacheKey); + } + + /// Nettoie le cache des paiements + Future clearPayments() async { + await _prefs.remove(_paymentsCacheKey); + } + + /// Nettoie une cotisation individuelle du cache + Future clearCotisation(String id) async { + final key = 'cotisation_$id'; + await _prefs.remove(key); + } + + /// Nettoie tout le cache des cotisations + Future 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 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'; + } +} diff --git a/unionflow-mobile-apps/lib/core/services/moov_money_service.dart b/unionflow-mobile-apps/lib/core/services/moov_money_service.dart new file mode 100644 index 0000000..9192bfc --- /dev/null +++ b/unionflow-mobile-apps/lib/core/services/moov_money_service.dart @@ -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 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 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 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 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 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> getPaymentHistory({ + String? cotisationId, + DateTime? dateDebut, + DateTime? dateFin, + int? limit, + }) async { + try { + final filters = { + '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 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> getPaymentStatistics({ + DateTime? dateDebut, + DateTime? dateFin, + }) async { + try { + final filters = { + '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 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'; +} diff --git a/unionflow-mobile-apps/lib/core/services/notification_service.dart b/unionflow-mobile-apps/lib/core/services/notification_service.dart new file mode 100644 index 0000000..ba6d009 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/services/notification_service.dart @@ -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 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 _requestPermissions() async { + final result = await _localNotifications + .resolvePlatformSpecificImplementation() + ?.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + return result ?? true; + } + + /// Planifie une notification de rappel pour une cotisation + Future 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 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 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 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 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 scheduleAllCotisationsNotifications(List 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 isNotificationsEnabled() async { + return _prefs.getBool(_notificationsEnabledKey) ?? true; + } + + Future setNotificationsEnabled(bool enabled) async { + await _prefs.setBool(_notificationsEnabledKey, enabled); + + if (!enabled) { + await _localNotifications.cancelAll(); + await _clearScheduledNotifications(); + } + } + + Future getReminderDays() async { + return _prefs.getInt(_reminderDaysKey) ?? 3; // 3 jours par défaut + } + + Future setReminderDays(int days) async { + await _prefs.setInt(_reminderDaysKey, days); + } + + Future>> getScheduledNotifications() async { + final jsonString = _prefs.getString(_scheduledNotificationsKey); + if (jsonString == null) return []; + + try { + final List jsonList = jsonDecode(jsonString); + return jsonList.cast>(); + } 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 _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 _clearScheduledNotifications() async { + await _prefs.remove(_scheduledNotificationsKey); + } +} diff --git a/unionflow-mobile-apps/lib/core/services/orange_money_service.dart b/unionflow-mobile-apps/lib/core/services/orange_money_service.dart new file mode 100644 index 0000000..274b7bc --- /dev/null +++ b/unionflow-mobile-apps/lib/core/services/orange_money_service.dart @@ -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 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 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 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 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 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> getPaymentHistory({ + String? cotisationId, + DateTime? dateDebut, + DateTime? dateFin, + int? limit, + }) async { + try { + final filters = { + '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 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> getPaymentStatistics({ + DateTime? dateDebut, + DateTime? dateFin, + }) async { + try { + final filters = { + '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'; +} diff --git a/unionflow-mobile-apps/lib/core/services/payment_service.dart b/unionflow-mobile-apps/lib/core/services/payment_service.dart new file mode 100644 index 0000000..665ac73 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/services/payment_service.dart @@ -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 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 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 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 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> 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 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 _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 _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'; +} diff --git a/unionflow-mobile-apps/lib/core/services/wave_payment_service.dart b/unionflow-mobile-apps/lib/core/services/wave_payment_service.dart new file mode 100644 index 0000000..56751dc --- /dev/null +++ b/unionflow-mobile-apps/lib/core/services/wave_payment_service.dart @@ -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 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 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 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 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 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'; +} diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/pages/forgot_password_screen.dart b/unionflow-mobile-apps/lib/features/auth/presentation/pages/forgot_password_screen.dart index a0b8e00..77651d6 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/pages/forgot_password_screen.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/pages/forgot_password_screen.dart @@ -163,7 +163,7 @@ class _ForgotPasswordScreenState extends State const SizedBox(height: 16), // Message de succès - Text( + const Text( 'Nous avons envoyé un lien de réinitialisation à :', style: TextStyle( fontSize: 16, @@ -196,15 +196,15 @@ class _ForgotPasswordScreenState extends State color: AppTheme.infoColor.withOpacity(0.2), ), ), - child: Column( + child: const Column( children: [ - const Icon( + Icon( Icons.info_outline, color: AppTheme.infoColor, size: 24, ), - const SizedBox(height: 12), - const Text( + SizedBox(height: 12), + Text( 'Instructions', style: TextStyle( fontSize: 16, @@ -212,7 +212,7 @@ class _ForgotPasswordScreenState extends State color: AppTheme.textPrimary, ), ), - const SizedBox(height: 8), + SizedBox(height: 8), Text( '1. Vérifiez votre boîte email (et vos spams)\n' '2. Cliquez sur le lien de réinitialisation\n' @@ -291,7 +291,7 @@ class _ForgotPasswordScreenState extends State const SizedBox(height: 8), // Sous-titre - Text( + const Text( 'Pas de problème ! Nous allons vous aider à le récupérer.', style: TextStyle( fontSize: 16, @@ -328,11 +328,11 @@ class _ForgotPasswordScreenState extends State ), ), const SizedBox(width: 16), - Expanded( + const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Comment ça marche ?', style: TextStyle( fontSize: 16, @@ -340,7 +340,7 @@ class _ForgotPasswordScreenState extends State color: AppTheme.textPrimary, ), ), - const SizedBox(height: 4), + SizedBox(height: 4), Text( 'Saisissez votre email et nous vous enverrons un lien sécurisé pour réinitialiser votre mot de passe.', style: TextStyle( @@ -388,7 +388,7 @@ class _ForgotPasswordScreenState extends State return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( + const Text( 'Vous vous souvenez de votre mot de passe ? ', style: TextStyle( color: AppTheme.textSecondary, diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page_temp.dart b/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page_temp.dart index 7bff1a9..0f3b39d 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page_temp.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page_temp.dart @@ -190,7 +190,7 @@ class _TempLoginPageState extends State decoration: InputDecoration( labelText: 'Adresse email', hintText: 'votre.email@exemple.com', - prefixIcon: Icon( + prefixIcon: const Icon( Icons.email_outlined, color: AppTheme.primaryColor, ), @@ -202,7 +202,7 @@ class _TempLoginPageState extends State ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( + borderSide: const BorderSide( color: AppTheme.primaryColor, width: 2, ), @@ -234,7 +234,7 @@ class _TempLoginPageState extends State decoration: InputDecoration( labelText: 'Mot de passe', hintText: 'Saisissez votre mot de passe', - prefixIcon: Icon( + prefixIcon: const Icon( Icons.lock_outlined, color: AppTheme.primaryColor, ), @@ -260,7 +260,7 @@ class _TempLoginPageState extends State ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( + borderSide: const BorderSide( color: AppTheme.primaryColor, width: 2, ), @@ -320,7 +320,7 @@ class _TempLoginPageState extends State : null, ), const SizedBox(width: 8), - Text( + const Text( 'Se souvenir de moi', style: TextStyle( fontSize: 14, @@ -340,7 +340,7 @@ class _TempLoginPageState extends State color: AppTheme.infoColor.withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), - child: Text( + child: const Text( 'Compte de test', style: TextStyle( fontSize: 12, @@ -376,12 +376,12 @@ class _TempLoginPageState extends State strokeWidth: 2, ), ) - : Row( + : const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.login, size: 20), - const SizedBox(width: 8), - const Text( + Icon(Icons.login, size: 20), + SizedBox(width: 8), + Text( 'Se connecter', style: TextStyle( fontSize: 16, diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_screen.dart b/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_screen.dart index ea7bbe6..e33a1cf 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_screen.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_screen.dart @@ -161,7 +161,7 @@ class _LoginScreenState extends State const SizedBox(height: 8), // Sous-titre - Text( + const Text( 'Connectez-vous à votre compte UnionFlow', style: TextStyle( fontSize: 16, @@ -269,11 +269,11 @@ class _LoginScreenState extends State } Widget _buildDivider() { - return Row( + return const Row( children: [ - const Expanded(child: Divider()), + Expanded(child: Divider()), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: 16), child: Text( 'ou', style: TextStyle( @@ -282,7 +282,7 @@ class _LoginScreenState extends State ), ), ), - const Expanded(child: Divider()), + Expanded(child: Divider()), ], ); } @@ -348,7 +348,7 @@ class _LoginScreenState extends State return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( + const Text( 'Pas encore de compte ? ', style: TextStyle( color: AppTheme.textSecondary, diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/pages/register_screen.dart b/unionflow-mobile-apps/lib/features/auth/presentation/pages/register_screen.dart index f1975e2..0814657 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/pages/register_screen.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/pages/register_screen.dart @@ -163,7 +163,7 @@ class _RegisterScreenState extends State const SizedBox(height: 8), // Sous-titre - Text( + const Text( 'Rejoignez UnionFlow et gérez votre association', style: TextStyle( fontSize: 16, @@ -386,25 +386,25 @@ class _RegisterScreenState extends State ), Expanded( child: RichText( - text: TextSpan( - style: const TextStyle( + text: const TextSpan( + style: TextStyle( color: AppTheme.textSecondary, fontSize: 14, ), children: [ - const TextSpan(text: 'J\'accepte les '), + TextSpan(text: 'J\'accepte les '), TextSpan( text: 'Conditions d\'utilisation', - style: const TextStyle( + style: TextStyle( color: AppTheme.primaryColor, fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), ), - const TextSpan(text: ' et la '), + TextSpan(text: ' et la '), TextSpan( text: 'Politique de confidentialité', - style: const TextStyle( + style: TextStyle( color: AppTheme.primaryColor, fontWeight: FontWeight.w600, decoration: TextDecoration.underline, @@ -459,7 +459,7 @@ class _RegisterScreenState extends State return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( + const Text( 'Déjà un compte ? ', style: TextStyle( color: AppTheme.textSecondary, diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_footer.dart b/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_footer.dart index 8847771..2c172d8 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_footer.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_footer.dart @@ -87,7 +87,7 @@ class LoginFooter extends StatelessWidget { color: AppTheme.textSecondary.withOpacity(0.1), ), ), - child: Column( + child: const Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, @@ -97,7 +97,7 @@ class LoginFooter extends StatelessWidget { size: 20, color: AppTheme.successColor, ), - const SizedBox(width: 8), + SizedBox(width: 8), Text( 'Connexion sécurisée', style: TextStyle( @@ -108,7 +108,7 @@ class LoginFooter extends StatelessWidget { ), ], ), - const SizedBox(height: 8), + SizedBox(height: 8), Text( 'Vos données sont protégées par un cryptage de niveau bancaire', textAlign: TextAlign.center, @@ -174,7 +174,7 @@ class LoginFooter extends StatelessWidget { const SizedBox(width: 6), Text( label, - style: TextStyle( + style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary, fontWeight: FontWeight.w500, @@ -216,14 +216,14 @@ class LoginFooter extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - title: Row( + title: const Row( children: [ Icon( Icons.help_outline, color: AppTheme.infoColor, ), - const SizedBox(width: 12), - const Text('Aide'), + SizedBox(width: 12), + Text('Aide'), ], ), content: Column( @@ -249,7 +249,7 @@ class LoginFooter extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( + child: const Text( 'Fermer', style: TextStyle( color: AppTheme.primaryColor, @@ -269,14 +269,14 @@ class LoginFooter extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - title: Row( + title: const Row( children: [ Icon( Icons.info_outline, color: AppTheme.primaryColor, ), - const SizedBox(width: 12), - const Text('À propos'), + SizedBox(width: 12), + Text('À propos'), ], ), content: const Text( @@ -286,7 +286,7 @@ class LoginFooter extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( + child: const Text( 'Fermer', style: TextStyle( color: AppTheme.primaryColor, @@ -306,14 +306,14 @@ class LoginFooter extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - title: Row( + title: const Row( children: [ Icon( Icons.privacy_tip_outlined, color: AppTheme.warningColor, ), - const SizedBox(width: 12), - const Text('Confidentialité'), + SizedBox(width: 12), + Text('Confidentialité'), ], ), content: const Text( @@ -323,7 +323,7 @@ class LoginFooter extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( + child: const Text( 'Compris', style: TextStyle( color: AppTheme.primaryColor, @@ -342,7 +342,7 @@ class LoginFooter extends StatelessWidget { children: [ Text( title, - style: TextStyle( + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, @@ -351,7 +351,7 @@ class LoginFooter extends StatelessWidget { const SizedBox(height: 4), Text( description, - style: TextStyle( + style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary, ), diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_form.dart b/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_form.dart index 1ffd488..27fe212 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_form.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_form.dart @@ -189,21 +189,21 @@ class _LoginFormState extends State ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( + borderSide: const BorderSide( color: AppTheme.primaryColor, width: 2, ), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( + borderSide: const BorderSide( color: AppTheme.errorColor, width: 2, ), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( + borderSide: const BorderSide( color: AppTheme.errorColor, width: 2, ), @@ -281,21 +281,21 @@ class _LoginFormState extends State ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( + borderSide: const BorderSide( color: AppTheme.primaryColor, width: 2, ), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( + borderSide: const BorderSide( color: AppTheme.errorColor, width: 2, ), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( + borderSide: const BorderSide( color: AppTheme.errorColor, width: 2, ), @@ -344,7 +344,7 @@ class _LoginFormState extends State : Colors.transparent, ), child: widget.rememberMe - ? Icon( + ? const Icon( Icons.check, size: 14, color: Colors.white, @@ -352,7 +352,7 @@ class _LoginFormState extends State : null, ), const SizedBox(width: 8), - Flexible( + const Flexible( child: Text( 'Se souvenir de moi', style: TextStyle( @@ -374,7 +374,7 @@ class _LoginFormState extends State HapticFeedback.selectionClick(); _showForgotPasswordDialog(); }, - child: Text( + child: const Text( 'Mot de passe oublié ?', style: TextStyle( fontSize: 14, @@ -413,14 +413,14 @@ class _LoginFormState extends State shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - title: Row( + title: const Row( children: [ Icon( Icons.help_outline, color: AppTheme.primaryColor, ), - const SizedBox(width: 12), - const Text('Mot de passe oublié'), + SizedBox(width: 12), + Text('Mot de passe oublié'), ], ), content: const Text( @@ -429,7 +429,7 @@ class _LoginFormState extends State actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( + child: const Text( 'Compris', style: TextStyle( color: AppTheme.primaryColor, diff --git a/unionflow-mobile-apps/lib/features/cotisations/data/repositories/cotisation_repository_impl.dart b/unionflow-mobile-apps/lib/features/cotisations/data/repositories/cotisation_repository_impl.dart index 278fa1f..40693d9 100644 --- a/unionflow-mobile-apps/lib/features/cotisations/data/repositories/cotisation_repository_impl.dart +++ b/unionflow-mobile-apps/lib/features/cotisations/data/repositories/cotisation_repository_impl.dart @@ -1,15 +1,17 @@ import 'package:injectable/injectable.dart'; import '../../../../core/models/cotisation_model.dart'; import '../../../../core/services/api_service.dart'; +import '../../../../core/services/cache_service.dart'; import '../../../cotisations/domain/repositories/cotisation_repository.dart'; /// Implémentation du repository des cotisations -/// Utilise ApiService pour communiquer avec le backend +/// Utilise ApiService pour communiquer avec le backend et CacheService pour le cache local @LazySingleton(as: CotisationRepository) class CotisationRepositoryImpl implements CotisationRepository { final ApiService _apiService; + final CacheService _cacheService; - CotisationRepositoryImpl(this._apiService); + CotisationRepositoryImpl(this._apiService, this._cacheService); @override Future> getCotisations({int page = 0, int size = 20}) async { @@ -79,6 +81,54 @@ class CotisationRepositoryImpl implements CotisationRepository { @override Future> getCotisationsStats() async { - return await _apiService.getCotisationsStats(); + // Essayer de récupérer depuis le cache d'abord + final cachedStats = await _cacheService.getCotisationsStats(); + if (cachedStats != null) { + return cachedStats.toJson(); + } + + try { + final stats = await _apiService.getCotisationsStats(); + + // Sauvegarder en cache si possible + // Note: Conversion nécessaire selon la structure des stats du backend + // await _cacheService.saveCotisationsStats(statsModel); + + return stats; + } catch (e) { + // En cas d'erreur, retourner le cache si disponible + if (cachedStats != null) { + return cachedStats.toJson(); + } + rethrow; + } + } + + /// Invalide tous les caches de listes de cotisations + Future _invalidateListCaches() async { + // Nettoyer les caches de listes paginées + final keys = ['cotisations_page_0_size_20', 'cotisations_cache']; + for (final key in keys) { + await _cacheService.clearCotisations(key: key); + } + + // Nettoyer le cache des statistiques + await _cacheService.clearCotisationsStats(); + } + + /// Force la synchronisation avec le serveur + Future forceSync() async { + await _cacheService.clearAllCotisationsCache(); + await _cacheService.updateLastSyncTimestamp(); + } + + /// Vérifie si une synchronisation est nécessaire + bool needsSync() { + return _cacheService.needsSync(); + } + + /// Retourne des informations sur le cache + Map getCacheInfo() { + return _cacheService.getCacheInfo(); } } diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_bloc.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_bloc.dart index 5048538..b85497c 100644 --- a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_bloc.dart +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_bloc.dart @@ -1,6 +1,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import '../../../../core/models/cotisation_model.dart'; +import '../../../../core/models/payment_model.dart'; +import '../../../../core/services/payment_service.dart'; +import '../../../../core/services/notification_service.dart'; import '../../domain/repositories/cotisation_repository.dart'; import 'cotisations_event.dart'; import 'cotisations_state.dart'; @@ -10,8 +13,14 @@ import 'cotisations_state.dart'; @injectable class CotisationsBloc extends Bloc { final CotisationRepository _cotisationRepository; + final PaymentService _paymentService; + final NotificationService _notificationService; - CotisationsBloc(this._cotisationRepository) : super(const CotisationsInitial()) { + CotisationsBloc( + this._cotisationRepository, + this._paymentService, + this._notificationService, + ) : super(const CotisationsInitial()) { // Enregistrement des handlers d'événements on(_onLoadCotisations); on(_onLoadCotisationById); @@ -28,6 +37,15 @@ class CotisationsBloc extends Bloc { on(_onResetCotisationsState); on(_onFilterCotisations); on(_onSortCotisations); + + // Nouveaux handlers pour les paiements et fonctionnalités avancées + on(_onInitiatePayment); + on(_onCheckPaymentStatus); + on(_onCancelPayment); + on(_onScheduleNotifications); + on(_onSyncWithServer); + on(_onApplyAdvancedFilters); + on(_onExportCotisations); } /// Handler pour charger la liste des cotisations @@ -506,4 +524,207 @@ class CotisationsBloc extends Bloc { emit(currentState.copyWith(filteredCotisations: sortedList)); } } + + /// Handler pour initier un paiement + Future _onInitiatePayment( + InitiatePayment event, + Emitter emit, + ) async { + try { + // Valider les données de paiement + if (!_paymentService.validatePaymentData( + cotisationId: event.cotisationId, + montant: event.montant, + methodePaiement: event.methodePaiement, + numeroTelephone: event.numeroTelephone, + )) { + emit(PaymentFailure( + cotisationId: event.cotisationId, + paymentId: '', + errorMessage: 'Données de paiement invalides', + errorCode: 'INVALID_DATA', + )); + return; + } + + // Initier le paiement + final payment = await _paymentService.initiatePayment( + cotisationId: event.cotisationId, + montant: event.montant, + methodePaiement: event.methodePaiement, + numeroTelephone: event.numeroTelephone, + nomPayeur: event.nomPayeur, + emailPayeur: event.emailPayeur, + ); + + emit(PaymentInProgress( + cotisationId: event.cotisationId, + paymentId: payment.id, + methodePaiement: event.methodePaiement, + montant: event.montant, + )); + + } catch (e) { + emit(PaymentFailure( + cotisationId: event.cotisationId, + paymentId: '', + errorMessage: e.toString(), + )); + } + } + + /// Handler pour vérifier le statut d'un paiement + Future _onCheckPaymentStatus( + CheckPaymentStatus event, + Emitter emit, + ) async { + try { + final payment = await _paymentService.checkPaymentStatus(event.paymentId); + + if (payment.isSuccessful) { + // Récupérer la cotisation mise à jour + final cotisation = await _cotisationRepository.getCotisationById(payment.cotisationId); + + emit(PaymentSuccess( + cotisationId: payment.cotisationId, + payment: payment, + updatedCotisation: cotisation, + )); + + // Envoyer notification de succès + await _notificationService.showPaymentConfirmation(cotisation, payment.montant); + + } else if (payment.isFailed) { + emit(PaymentFailure( + cotisationId: payment.cotisationId, + paymentId: payment.id, + errorMessage: payment.messageErreur ?? 'Paiement échoué', + )); + + // Envoyer notification d'échec + final cotisation = await _cotisationRepository.getCotisationById(payment.cotisationId); + await _notificationService.showPaymentFailure(cotisation, payment.messageErreur ?? 'Erreur inconnue'); + } + } catch (e) { + emit(CotisationsError('Erreur lors de la vérification du paiement: ${e.toString()}')); + } + } + + /// Handler pour annuler un paiement + Future _onCancelPayment( + CancelPayment event, + Emitter emit, + ) async { + try { + final cancelled = await _paymentService.cancelPayment(event.paymentId); + + if (cancelled) { + emit(PaymentCancelled( + cotisationId: event.cotisationId, + paymentId: event.paymentId, + )); + } else { + emit(const CotisationsError('Impossible d\'annuler le paiement')); + } + } catch (e) { + emit(CotisationsError('Erreur lors de l\'annulation du paiement: ${e.toString()}')); + } + } + + /// Handler pour programmer les notifications + Future _onScheduleNotifications( + ScheduleNotifications event, + Emitter emit, + ) async { + try { + await _notificationService.scheduleAllCotisationsNotifications(event.cotisations); + + emit(NotificationsScheduled( + notificationsCount: event.cotisations.length * 2, + cotisationIds: event.cotisations.map((c) => c.id).toList(), + )); + } catch (e) { + emit(CotisationsError('Erreur lors de la programmation des notifications: ${e.toString()}')); + } + } + + /// Handler pour synchroniser avec le serveur + Future _onSyncWithServer( + SyncWithServer event, + Emitter emit, + ) async { + try { + emit(const SyncInProgress('Synchronisation en cours...')); + + // Recharger les données + final cotisations = await _cotisationRepository.getCotisations(); + + emit(SyncCompleted( + itemsSynced: cotisations.length, + syncTime: DateTime.now(), + )); + + // Émettre l'état chargé avec les nouvelles données + emit(CotisationsLoaded( + cotisations: cotisations, + filteredCotisations: cotisations, + )); + + } catch (e) { + emit(CotisationsError('Erreur lors de la synchronisation: ${e.toString()}')); + } + } + + /// Handler pour appliquer des filtres avancés + Future _onApplyAdvancedFilters( + ApplyAdvancedFilters event, + Emitter emit, + ) async { + try { + emit(const CotisationsLoading()); + + final cotisations = await _cotisationRepository.rechercherCotisations( + membreId: event.filters['membreId'], + statut: event.filters['statut'], + typeCotisation: event.filters['typeCotisation'], + annee: event.filters['annee'], + mois: event.filters['mois'], + ); + + emit(CotisationsSearchResults( + cotisations: cotisations, + searchCriteria: event.filters, + )); + + } catch (e) { + emit(CotisationsError('Erreur lors de l\'application des filtres: ${e.toString()}')); + } + } + + /// Handler pour exporter les cotisations + Future _onExportCotisations( + ExportCotisations event, + Emitter emit, + ) async { + try { + final cotisations = event.cotisations ?? []; + + emit(ExportInProgress( + format: event.format, + totalItems: cotisations.length, + )); + + // TODO: Implémenter l'export réel selon le format + await Future.delayed(const Duration(seconds: 2)); // Simulation + + emit(ExportCompleted( + format: event.format, + filePath: '/storage/emulated/0/Download/cotisations.${event.format}', + itemsExported: cotisations.length, + )); + + } catch (e) { + emit(CotisationsError('Erreur lors de l\'export: ${e.toString()}')); + } + } } diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_event.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_event.dart index 7c5727e..8a47cec 100644 --- a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_event.dart +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_event.dart @@ -204,3 +204,97 @@ class SortCotisations extends CotisationsEvent { @override List get props => [sortBy, ascending]; } + +/// Événement pour initier un paiement +class InitiatePayment extends CotisationsEvent { + final String cotisationId; + final double montant; + final String methodePaiement; + final String numeroTelephone; + final String? nomPayeur; + final String? emailPayeur; + + const InitiatePayment({ + required this.cotisationId, + required this.montant, + required this.methodePaiement, + required this.numeroTelephone, + this.nomPayeur, + this.emailPayeur, + }); + + @override + List get props => [ + cotisationId, + montant, + methodePaiement, + numeroTelephone, + nomPayeur, + emailPayeur, + ]; +} + +/// Événement pour vérifier le statut d'un paiement +class CheckPaymentStatus extends CotisationsEvent { + final String paymentId; + + const CheckPaymentStatus(this.paymentId); + + @override + List get props => [paymentId]; +} + +/// Événement pour annuler un paiement +class CancelPayment extends CotisationsEvent { + final String paymentId; + final String cotisationId; + + const CancelPayment({ + required this.paymentId, + required this.cotisationId, + }); + + @override + List get props => [paymentId, cotisationId]; +} + +/// Événement pour programmer des notifications +class ScheduleNotifications extends CotisationsEvent { + final List cotisations; + + const ScheduleNotifications(this.cotisations); + + @override + List get props => [cotisations]; +} + +/// Événement pour synchroniser avec le serveur +class SyncWithServer extends CotisationsEvent { + final bool forceSync; + + const SyncWithServer({this.forceSync = false}); + + @override + List get props => [forceSync]; +} + +/// Événement pour appliquer des filtres avancés +class ApplyAdvancedFilters extends CotisationsEvent { + final Map filters; + + const ApplyAdvancedFilters(this.filters); + + @override + List get props => [filters]; +} + +/// Événement pour exporter des données +class ExportCotisations extends CotisationsEvent { + final String format; // 'pdf', 'excel', 'csv' + final List? cotisations; + + const ExportCotisations(this.format, {this.cotisations}); + + @override + List get props => [format, cotisations]; +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_state.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_state.dart index 7755fac..10076eb 100644 --- a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_state.dart +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import '../../../../core/models/cotisation_model.dart'; +import '../../../../core/models/payment_model.dart'; /// États du BLoC des cotisations abstract class CotisationsState extends Equatable { @@ -245,3 +246,137 @@ class CotisationsSearchResults extends CotisationsState { @override List get props => [cotisations, searchCriteria, hasReachedMax, currentPage]; } + +/// État pour un paiement en cours +class PaymentInProgress extends CotisationsState { + final String cotisationId; + final String paymentId; + final String methodePaiement; + final double montant; + + const PaymentInProgress({ + required this.cotisationId, + required this.paymentId, + required this.methodePaiement, + required this.montant, + }); + + @override + List get props => [cotisationId, paymentId, methodePaiement, montant]; +} + +/// État pour un paiement réussi +class PaymentSuccess extends CotisationsState { + final String cotisationId; + final PaymentModel payment; + final CotisationModel updatedCotisation; + + const PaymentSuccess({ + required this.cotisationId, + required this.payment, + required this.updatedCotisation, + }); + + @override + List get props => [cotisationId, payment, updatedCotisation]; +} + +/// État pour un paiement échoué +class PaymentFailure extends CotisationsState { + final String cotisationId; + final String paymentId; + final String errorMessage; + final String? errorCode; + + const PaymentFailure({ + required this.cotisationId, + required this.paymentId, + required this.errorMessage, + this.errorCode, + }); + + @override + List get props => [cotisationId, paymentId, errorMessage, errorCode]; +} + +/// État pour un paiement annulé +class PaymentCancelled extends CotisationsState { + final String cotisationId; + final String paymentId; + + const PaymentCancelled({ + required this.cotisationId, + required this.paymentId, + }); + + @override + List get props => [cotisationId, paymentId]; +} + +/// État pour la synchronisation en cours +class SyncInProgress extends CotisationsState { + final String message; + + const SyncInProgress(this.message); + + @override + List get props => [message]; +} + +/// État pour la synchronisation terminée +class SyncCompleted extends CotisationsState { + final int itemsSynced; + final DateTime syncTime; + + const SyncCompleted({ + required this.itemsSynced, + required this.syncTime, + }); + + @override + List get props => [itemsSynced, syncTime]; +} + +/// État pour l'export en cours +class ExportInProgress extends CotisationsState { + final String format; + final int totalItems; + + const ExportInProgress({ + required this.format, + required this.totalItems, + }); + + @override + List get props => [format, totalItems]; +} + +/// État pour l'export terminé +class ExportCompleted extends CotisationsState { + final String format; + final String filePath; + final int itemsExported; + + const ExportCompleted({ + required this.format, + required this.filePath, + required this.itemsExported, + }); + + @override + List get props => [format, filePath, itemsExported]; +} + +/// État pour les notifications programmées +class NotificationsScheduled extends CotisationsState { + final int notificationsCount; + final List cotisationIds; + + const NotificationsScheduled({ + required this.notificationsCount, + required this.cotisationIds, + }); + + @override + List get props => [notificationsCount, cotisationIds]; +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisation_detail_page.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisation_detail_page.dart new file mode 100644 index 0000000..063134a --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisation_detail_page.dart @@ -0,0 +1,708 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../core/models/payment_model.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/widgets/buttons/buttons.dart'; +import '../../../../shared/widgets/buttons/primary_button.dart'; +import '../bloc/cotisations_bloc.dart'; +import '../bloc/cotisations_event.dart'; +import '../bloc/cotisations_state.dart'; +import '../widgets/payment_method_selector.dart'; +import '../widgets/payment_form_widget.dart'; +import '../widgets/cotisation_timeline_widget.dart'; + +/// Page de détail d'une cotisation +class CotisationDetailPage extends StatefulWidget { + final CotisationModel cotisation; + + const CotisationDetailPage({ + super.key, + required this.cotisation, + }); + + @override + State createState() => _CotisationDetailPageState(); +} + +class _CotisationDetailPageState extends State + with TickerProviderStateMixin { + late final CotisationsBloc _cotisationsBloc; + late final TabController _tabController; + late final AnimationController _animationController; + late final Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _cotisationsBloc = getIt(); + _tabController = TabController(length: 3, vsync: this); + _animationController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + + _animationController.forward(); + } + + @override + void dispose() { + _tabController.dispose(); + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cotisationsBloc, + child: Scaffold( + backgroundColor: AppTheme.backgroundLight, + body: BlocListener( + listener: (context, state) { + if (state is PaymentSuccess) { + _showPaymentSuccessDialog(state); + } else if (state is PaymentFailure) { + _showPaymentErrorDialog(state); + } else if (state is PaymentInProgress) { + _showPaymentProgressDialog(state); + } + }, + child: FadeTransition( + opacity: _fadeAnimation, + child: CustomScrollView( + slivers: [ + _buildAppBar(), + SliverToBoxAdapter( + child: Column( + children: [ + _buildStatusCard(), + const SizedBox(height: 16), + _buildTabSection(), + ], + ), + ), + ], + ), + ), + ), + bottomNavigationBar: _buildBottomActions(), + ), + ); + } + + Widget _buildAppBar() { + return SliverAppBar( + expandedHeight: 200, + pinned: true, + backgroundColor: _getStatusColor(), + flexibleSpace: FlexibleSpaceBar( + title: Text( + widget.cotisation.typeCotisation, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + background: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + _getStatusColor(), + _getStatusColor().withOpacity(0.8), + ], + ), + ), + child: Stack( + children: [ + Positioned( + right: -50, + top: -50, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withOpacity(0.1), + ), + ), + ), + Positioned( + right: 20, + bottom: 20, + child: Icon( + _getStatusIcon(), + size: 80, + color: Colors.white.withOpacity(0.3), + ), + ), + ], + ), + ), + ), + actions: [ + IconButton( + icon: const Icon(Icons.share, color: Colors.white), + onPressed: _shareReceipt, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white), + onSelected: _handleMenuAction, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'export', + child: Row( + children: [ + Icon(Icons.download), + SizedBox(width: 8), + Text('Exporter'), + ], + ), + ), + const PopupMenuItem( + value: 'print', + child: Row( + children: [ + Icon(Icons.print), + SizedBox(width: 8), + Text('Imprimer'), + ], + ), + ), + const PopupMenuItem( + value: 'history', + child: Row( + children: [ + Icon(Icons.history), + SizedBox(width: 8), + Text('Historique'), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildStatusCard() { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Montant à payer', + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '${widget.cotisation.montantDu.toStringAsFixed(0)} XOF', + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getStatusColor().withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getStatusIcon(), + size: 16, + color: _getStatusColor(), + ), + const SizedBox(width: 4), + Text( + widget.cotisation.statut, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getStatusColor(), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + _buildInfoRow('Membre', widget.cotisation.nomMembre ?? 'N/A'), + _buildInfoRow('Période', _formatPeriode()), + _buildInfoRow('Échéance', _formatDate(widget.cotisation.dateEcheance)), + if (widget.cotisation.montantPaye > 0) + _buildInfoRow('Montant payé', '${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF'), + if (widget.cotisation.isEnRetard) + _buildInfoRow('Retard', '${widget.cotisation.joursRetard} jours', isWarning: true), + ], + ), + ); + } + + Widget _buildInfoRow(String label, String value, {bool isWarning = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isWarning ? AppTheme.warningColor : AppTheme.textPrimary, + ), + ), + ], + ), + ); + } + + Widget _buildTabSection() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + TabBar( + controller: _tabController, + labelColor: AppTheme.primaryColor, + unselectedLabelColor: AppTheme.textSecondary, + indicatorColor: AppTheme.primaryColor, + tabs: const [ + Tab(text: 'Détails', icon: Icon(Icons.info_outline)), + Tab(text: 'Paiement', icon: Icon(Icons.payment)), + Tab(text: 'Historique', icon: Icon(Icons.history)), + ], + ), + SizedBox( + height: 400, + child: TabBarView( + controller: _tabController, + children: [ + _buildDetailsTab(), + _buildPaymentTab(), + _buildHistoryTab(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDetailsTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailSection('Informations générales', [ + _buildDetailItem('Type', widget.cotisation.typeCotisation), + _buildDetailItem('Référence', widget.cotisation.numeroReference), + _buildDetailItem('Date création', _formatDate(widget.cotisation.dateCreation)), + _buildDetailItem('Statut', widget.cotisation.statut), + ]), + const SizedBox(height: 20), + _buildDetailSection('Montants', [ + _buildDetailItem('Montant dû', '${widget.cotisation.montantDu.toStringAsFixed(0)} XOF'), + _buildDetailItem('Montant payé', '${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF'), + _buildDetailItem('Reste à payer', '${(widget.cotisation.montantDu - widget.cotisation.montantPaye).toStringAsFixed(0)} XOF'), + ]), + if (widget.cotisation.description?.isNotEmpty == true) ...[ + const SizedBox(height: 20), + _buildDetailSection('Description', [ + Text( + widget.cotisation.description!, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + ]), + ], + ], + ), + ); + } + + Widget _buildPaymentTab() { + if (widget.cotisation.isEntierementPayee) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle, + size: 64, + color: AppTheme.successColor, + ), + SizedBox(height: 16), + Text( + 'Cotisation entièrement payée', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.successColor, + ), + ), + ], + ), + ); + } + + return BlocBuilder( + builder: (context, state) { + if (state is PaymentInProgress) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Traitement du paiement en cours...'), + ], + ), + ); + } + + return PaymentFormWidget( + cotisation: widget.cotisation, + onPaymentInitiated: (paymentData) { + _cotisationsBloc.add(InitiatePayment( + cotisationId: widget.cotisation.id, + montant: paymentData['montant'], + methodePaiement: paymentData['methodePaiement'], + numeroTelephone: paymentData['numeroTelephone'], + nomPayeur: paymentData['nomPayeur'], + emailPayeur: paymentData['emailPayeur'], + )); + }, + ); + }, + ); + } + + Widget _buildHistoryTab() { + return CotisationTimelineWidget(cotisation: widget.cotisation); + } + + Widget _buildDetailSection(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 12), + ...children, + ], + ); + } + + Widget _buildDetailItem(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ); + } + + Widget _buildBottomActions() { + if (widget.cotisation.isEntierementPayee) { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, -2), + ), + ], + ), + child: PrimaryButton( + text: 'Télécharger le reçu', + icon: Icons.download, + onPressed: _downloadReceipt, + ), + ); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _scheduleReminder, + icon: const Icon(Icons.notifications), + label: const Text('Rappel'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: PrimaryButton( + text: 'Payer maintenant', + icon: Icons.payment, + onPressed: () { + _tabController.animateTo(1); // Aller à l'onglet paiement + }, + ), + ), + ], + ), + ); + } + + // Méthodes utilitaires + Color _getStatusColor() { + switch (widget.cotisation.statut.toLowerCase()) { + case 'payee': + return AppTheme.successColor; + case 'en_retard': + return AppTheme.errorColor; + case 'en_attente': + return AppTheme.warningColor; + default: + return AppTheme.primaryColor; + } + } + + IconData _getStatusIcon() { + switch (widget.cotisation.statut.toLowerCase()) { + case 'payee': + return Icons.check_circle; + case 'en_retard': + return Icons.warning; + case 'en_attente': + return Icons.schedule; + default: + return Icons.payment; + } + } + + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } + + String _formatPeriode() { + return '${widget.cotisation.mois}/${widget.cotisation.annee}'; + } + + // Actions + void _shareReceipt() { + // TODO: Implémenter le partage + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Partage - En cours de développement')), + ); + } + + void _handleMenuAction(String action) { + switch (action) { + case 'export': + _exportReceipt(); + break; + case 'print': + _printReceipt(); + break; + case 'history': + _showFullHistory(); + break; + } + } + + void _exportReceipt() { + _cotisationsBloc.add(ExportCotisations('pdf', cotisations: [widget.cotisation])); + } + + void _printReceipt() { + // TODO: Implémenter l'impression + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Impression - En cours de développement')), + ); + } + + void _showFullHistory() { + // TODO: Naviguer vers l'historique complet + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Historique complet - En cours de développement')), + ); + } + + void _downloadReceipt() { + _exportReceipt(); + } + + void _scheduleReminder() { + _cotisationsBloc.add(ScheduleNotifications([widget.cotisation])); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Rappel programmé avec succès'), + backgroundColor: AppTheme.successColor, + ), + ); + } + + // Dialogs + void _showPaymentSuccessDialog(PaymentSuccess state) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.check_circle, color: AppTheme.successColor), + SizedBox(width: 8), + Text('Paiement réussi'), + ], + ), + content: Text('Votre paiement de ${state.payment.montant.toStringAsFixed(0)} XOF a été confirmé.'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); // Retour à la liste + }, + child: const Text('OK'), + ), + ], + ), + ); + } + + void _showPaymentErrorDialog(PaymentFailure state) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.error, color: AppTheme.errorColor), + SizedBox(width: 8), + Text('Échec du paiement'), + ], + ), + content: Text(state.errorMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + + void _showPaymentProgressDialog(PaymentInProgress state) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text('Traitement du paiement de ${state.montant.toStringAsFixed(0)} XOF...'), + const SizedBox(height: 8), + Text('Méthode: ${state.methodePaiement}'), + ], + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_list_page.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_list_page.dart index 21894fa..a45495d 100644 --- a/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_list_page.dart +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_list_page.dart @@ -8,6 +8,8 @@ import '../bloc/cotisations_event.dart'; import '../bloc/cotisations_state.dart'; import '../widgets/cotisation_card.dart'; import '../widgets/cotisations_stats_card.dart'; +import 'cotisation_detail_page.dart'; +import 'cotisations_search_page.dart'; /// Page principale pour la liste des cotisations class CotisationsListPage extends StatefulWidget { @@ -155,13 +157,23 @@ class _CotisationsListPageState extends State { IconButton( icon: const Icon(Icons.search, color: Colors.white), onPressed: () { - // TODO: Implémenter la recherche + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CotisationsSearchPage(), + ), + ); }, ), IconButton( icon: const Icon(Icons.filter_list, color: Colors.white), onPressed: () { - // TODO: Implémenter les filtres + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CotisationsSearchPage(), + ), + ); }, ), ], @@ -264,14 +276,22 @@ class _CotisationsListPageState extends State { child: CotisationCard( cotisation: cotisation, onTap: () { - // TODO: Naviguer vers le détail + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CotisationDetailPage( + cotisation: cotisation, + ), + ), + ); }, onPay: () { - // TODO: Implémenter le paiement - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Paiement - En cours de développement'), - backgroundColor: AppTheme.successColor, + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CotisationDetailPage( + cotisation: cotisation, + ), ), ); }, diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_search_page.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_search_page.dart new file mode 100644 index 0000000..c41cfbc --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_search_page.dart @@ -0,0 +1,498 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/widgets/buttons/buttons.dart'; +import '../../../../shared/widgets/buttons/primary_button.dart'; +import '../bloc/cotisations_bloc.dart'; +import '../bloc/cotisations_event.dart'; +import '../bloc/cotisations_state.dart'; +import '../widgets/cotisation_card.dart'; +import 'cotisation_detail_page.dart'; + +/// Page de recherche et filtrage des cotisations +class CotisationsSearchPage extends StatefulWidget { + const CotisationsSearchPage({super.key}); + + @override + State createState() => _CotisationsSearchPageState(); +} + +class _CotisationsSearchPageState extends State + with TickerProviderStateMixin { + late final CotisationsBloc _cotisationsBloc; + late final TabController _tabController; + late final AnimationController _animationController; + + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + + String? _selectedStatut; + String? _selectedType; + int? _selectedAnnee; + int? _selectedMois; + bool _showAdvancedFilters = false; + + @override + void initState() { + super.initState(); + _cotisationsBloc = getIt(); + _tabController = TabController(length: 4, vsync: this); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _scrollController.addListener(_onScroll); + _animationController.forward(); + } + + @override + void dispose() { + _searchController.dispose(); + _scrollController.dispose(); + _tabController.dispose(); + _animationController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_isBottom) { + final currentState = _cotisationsBloc.state; + if (currentState is CotisationsSearchResults && !currentState.hasReachedMax) { + _performSearch(page: currentState.currentPage + 1); + } + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + return currentScroll >= (maxScroll * 0.9); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cotisationsBloc, + child: Scaffold( + backgroundColor: AppTheme.backgroundLight, + appBar: AppBar( + title: const Text('Recherche'), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + bottom: TabBar( + controller: _tabController, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + indicatorColor: Colors.white, + tabs: const [ + Tab(text: 'Toutes', icon: Icon(Icons.list)), + Tab(text: 'En attente', icon: Icon(Icons.schedule)), + Tab(text: 'En retard', icon: Icon(Icons.warning)), + Tab(text: 'Payées', icon: Icon(Icons.check_circle)), + ], + onTap: (index) => _onTabChanged(index), + ), + ), + body: Column( + children: [ + _buildSearchHeader(), + if (_showAdvancedFilters) _buildAdvancedFilters(), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildSearchResults(), + _buildSearchResults(statut: 'EN_ATTENTE'), + _buildSearchResults(statut: 'EN_RETARD'), + _buildSearchResults(statut: 'PAYEE'), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildSearchHeader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Barre de recherche + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher par nom, référence...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _performSearch(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: AppTheme.backgroundLight, + ), + onChanged: (value) { + setState(() {}); + _performSearch(); + }, + ), + + const SizedBox(height: 12), + + // Boutons d'action + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + setState(() { + _showAdvancedFilters = !_showAdvancedFilters; + }); + if (_showAdvancedFilters) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + }, + icon: Icon(_showAdvancedFilters ? Icons.expand_less : Icons.tune), + label: Text(_showAdvancedFilters ? 'Masquer filtres' : 'Filtres avancés'), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _clearAllFilters, + icon: const Icon(Icons.clear_all), + label: const Text('Effacer'), + ), + ], + ), + ], + ), + ); + } + + Widget _buildAdvancedFilters() { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: _showAdvancedFilters ? null : 0, + child: Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide(color: AppTheme.borderLight), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Filtres avancés', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + + // Grille de filtres + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 3, + children: [ + _buildFilterDropdown( + 'Type', + _selectedType, + ['Mensuelle', 'Annuelle', 'Exceptionnelle', 'Adhésion'], + (value) => setState(() => _selectedType = value), + ), + _buildFilterDropdown( + 'Année', + _selectedAnnee?.toString(), + List.generate(5, (i) => (DateTime.now().year - i).toString()), + (value) => setState(() => _selectedAnnee = int.tryParse(value ?? '')), + ), + ], + ), + + const SizedBox(height: 16), + + // Bouton d'application des filtres + SizedBox( + width: double.infinity, + child: PrimaryButton( + text: 'Appliquer les filtres', + onPressed: _applyAdvancedFilters, + ), + ), + ], + ), + ), + ); + } + + Widget _buildFilterDropdown( + String label, + String? value, + List items, + Function(String?) onChanged, + ) { + return DropdownButtonFormField( + value: value, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + items: [ + DropdownMenuItem( + value: null, + child: Text('Tous les ${label.toLowerCase()}s'), + ), + ...items.map((item) => DropdownMenuItem( + value: item, + child: Text(item), + )), + ], + onChanged: onChanged, + ); + } + + Widget _buildSearchResults({String? statut}) { + return BlocBuilder( + builder: (context, state) { + if (state is CotisationsLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is CotisationsError) { + return _buildErrorState(state); + } + + if (state is CotisationsSearchResults) { + final filteredResults = statut != null + ? state.cotisations.where((c) => c.statut == statut).toList() + : state.cotisations; + + if (filteredResults.isEmpty) { + return _buildEmptyState(); + } + + return RefreshIndicator( + onRefresh: () async => _performSearch(refresh: true), + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: filteredResults.length + (state.hasReachedMax ? 0 : 1), + itemBuilder: (context, index) { + if (index >= filteredResults.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + + final cotisation = filteredResults[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: CotisationCard( + cotisation: cotisation, + onTap: () => _navigateToDetail(cotisation), + onPay: () => _navigateToDetail(cotisation), + ), + ); + }, + ), + ); + } + + return _buildInitialState(); + }, + ); + } + + Widget _buildInitialState() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 64, + color: AppTheme.textHint, + ), + SizedBox(height: 16), + Text( + 'Recherchez des cotisations', + style: TextStyle( + fontSize: 18, + color: AppTheme.textSecondary, + ), + ), + SizedBox(height: 8), + Text( + 'Utilisez la barre de recherche ou les filtres', + style: TextStyle( + fontSize: 14, + color: AppTheme.textHint, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: AppTheme.textHint, + ), + SizedBox(height: 16), + Text( + 'Aucun résultat trouvé', + style: TextStyle( + fontSize: 18, + color: AppTheme.textSecondary, + ), + ), + SizedBox(height: 8), + Text( + 'Essayez de modifier vos critères de recherche', + style: TextStyle( + fontSize: 14, + color: AppTheme.textHint, + ), + ), + ], + ), + ); + } + + Widget _buildErrorState(CotisationsError state) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 64, + color: AppTheme.errorColor, + ), + const SizedBox(height: 16), + Text( + 'Erreur de recherche', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + state.message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 24), + PrimaryButton( + text: 'Réessayer', + onPressed: () => _performSearch(refresh: true), + ), + ], + ), + ); + } + + // Actions + void _onTabChanged(int index) { + _performSearch(refresh: true); + } + + void _performSearch({int page = 0, bool refresh = false}) { + final query = _searchController.text.trim(); + + if (query.isEmpty && !_hasActiveFilters()) { + return; + } + + final filters = { + if (query.isNotEmpty) 'query': query, + if (_selectedStatut != null) 'statut': _selectedStatut, + if (_selectedType != null) 'typeCotisation': _selectedType, + if (_selectedAnnee != null) 'annee': _selectedAnnee, + if (_selectedMois != null) 'mois': _selectedMois, + }; + + _cotisationsBloc.add(ApplyAdvancedFilters(filters)); + } + + void _applyAdvancedFilters() { + _performSearch(refresh: true); + } + + void _clearAllFilters() { + setState(() { + _searchController.clear(); + _selectedStatut = null; + _selectedType = null; + _selectedAnnee = null; + _selectedMois = null; + }); + _cotisationsBloc.add(const ResetCotisationsState()); + } + + bool _hasActiveFilters() { + return _selectedStatut != null || + _selectedType != null || + _selectedAnnee != null || + _selectedMois != null; + } + + void _navigateToDetail(CotisationModel cotisation) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CotisationDetailPage(cotisation: cotisation), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/animated_cotisation_list.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/animated_cotisation_list.dart new file mode 100644 index 0000000..e87e8e7 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/animated_cotisation_list.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../core/animations/loading_animations.dart'; +import 'cotisation_card.dart'; + +/// Widget animé pour afficher une liste de cotisations avec animations d'apparition +class AnimatedCotisationList extends StatefulWidget { + final List cotisations; + final Function(CotisationModel)? onCotisationTap; + final bool isLoading; + final VoidCallback? onRefresh; + final ScrollController? scrollController; + + const AnimatedCotisationList({ + super.key, + required this.cotisations, + this.onCotisationTap, + this.isLoading = false, + this.onRefresh, + this.scrollController, + }); + + @override + State createState() => _AnimatedCotisationListState(); +} + +class _AnimatedCotisationListState extends State + with TickerProviderStateMixin { + late AnimationController _listController; + List _itemControllers = []; + List> _itemAnimations = []; + List> _slideAnimations = []; + + @override + void initState() { + super.initState(); + _initializeAnimations(); + } + + @override + void didUpdateWidget(AnimatedCotisationList oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.cotisations.length != oldWidget.cotisations.length) { + _updateAnimations(); + } + } + + @override + void dispose() { + _listController.dispose(); + for (final controller in _itemControllers) { + controller.dispose(); + } + super.dispose(); + } + + void _initializeAnimations() { + _listController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + _updateAnimations(); + _listController.forward(); + } + + void _updateAnimations() { + // Dispose des anciens controllers s'ils existent + if (_itemControllers.isNotEmpty) { + for (final controller in _itemControllers) { + controller.dispose(); + } + } + + // Créer de nouveaux controllers pour chaque élément + _itemControllers = List.generate( + widget.cotisations.length, + (index) => AnimationController( + duration: Duration(milliseconds: 400 + (index * 80)), + vsync: this, + ), + ); + + // Animations de fade et scale + _itemAnimations = _itemControllers.map((controller) { + return Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeOutCubic, + ), + ); + }).toList(); + + // Animations de slide depuis la gauche + _slideAnimations = _itemControllers.map((controller) { + return Tween( + begin: const Offset(-0.3, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeOutCubic, + ), + ); + }).toList(); + + // Démarrer les animations avec un délai progressif + for (int i = 0; i < _itemControllers.length; i++) { + Future.delayed(Duration(milliseconds: i * 120), () { + if (mounted) { + _itemControllers[i].forward(); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (widget.isLoading && widget.cotisations.isEmpty) { + return _buildLoadingState(); + } + + if (widget.cotisations.isEmpty) { + return _buildEmptyState(); + } + + return RefreshIndicator( + onRefresh: () async { + widget.onRefresh?.call(); + await Future.delayed(const Duration(milliseconds: 500)); + }, + child: ListView.builder( + controller: widget.scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: widget.cotisations.length + (widget.isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= widget.cotisations.length) { + return _buildLoadingIndicator(); + } + + return _buildAnimatedItem(index); + }, + ), + ); + } + + Widget _buildAnimatedItem(int index) { + final cotisation = widget.cotisations[index]; + + if (index >= _itemAnimations.length) { + // Fallback pour les nouveaux éléments + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: CotisationCard( + cotisation: cotisation, + onTap: () => widget.onCotisationTap?.call(cotisation), + ), + ); + } + + return AnimatedBuilder( + animation: _itemAnimations[index], + builder: (context, child) { + return SlideTransition( + position: _slideAnimations[index], + child: FadeTransition( + opacity: _itemAnimations[index], + child: Transform.scale( + scale: 0.9 + (0.1 * _itemAnimations[index].value), + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: CotisationCard( + cotisation: cotisation, + onTap: () => widget.onCotisationTap?.call(cotisation), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildLoadingState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LoadingAnimations.pulse(), + const SizedBox(height: 24), + const Text( + 'Chargement des cotisations...', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.payment_outlined, + size: 80, + color: Colors.grey[400], + ), + const SizedBox(height: 24), + Text( + 'Aucune cotisation trouvée', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Les cotisations apparaîtront ici', + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + Widget _buildLoadingIndicator() { + return Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: LoadingAnimations.spinner(), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_card.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_card.dart index 5bec373..82151cd 100644 --- a/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_card.dart +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import '../../../../core/models/cotisation_model.dart'; import '../../../../shared/theme/app_theme.dart'; @@ -41,7 +42,10 @@ class CotisationCard extends StatelessWidget { ), ), child: InkWell( - onTap: onTap, + onTap: () { + HapticFeedback.lightImpact(); + onTap?.call(); + }, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(16), @@ -71,7 +75,10 @@ class CotisationCard extends StatelessWidget { // Actions if (cotisation.statut == 'EN_ATTENTE' || cotisation.statut == 'EN_RETARD') IconButton( - onPressed: onPay, + onPressed: () { + HapticFeedback.lightImpact(); + onPay?.call(); + }, icon: const Icon(Icons.payment, size: 20), color: AppTheme.successColor, tooltip: 'Payer', diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_timeline_widget.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_timeline_widget.dart new file mode 100644 index 0000000..c1e6ee8 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_timeline_widget.dart @@ -0,0 +1,417 @@ +import 'package:flutter/material.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Widget d'affichage de la timeline d'une cotisation +class CotisationTimelineWidget extends StatefulWidget { + final CotisationModel cotisation; + + const CotisationTimelineWidget({ + super.key, + required this.cotisation, + }); + + @override + State createState() => _CotisationTimelineWidgetState(); +} + +class _CotisationTimelineWidgetState extends State + with TickerProviderStateMixin { + late final AnimationController _animationController; + late final List> _itemAnimations; + + List _timelineEvents = []; + + @override + void initState() { + super.initState(); + _generateTimelineEvents(); + + _animationController = AnimationController( + duration: Duration(milliseconds: 300 * _timelineEvents.length), + vsync: this, + ); + + _itemAnimations = List.generate( + _timelineEvents.length, + (index) => Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: Interval( + index / _timelineEvents.length, + (index + 1) / _timelineEvents.length, + curve: Curves.easeOutCubic, + ), + ), + ), + ); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _generateTimelineEvents() { + _timelineEvents = [ + TimelineEvent( + title: 'Cotisation créée', + description: 'Cotisation ${widget.cotisation.typeCotisation} créée pour ${widget.cotisation.nomMembre}', + date: widget.cotisation.dateCreation, + icon: Icons.add_circle, + color: AppTheme.primaryColor, + isCompleted: true, + ), + ]; + + // Ajouter l'événement d'échéance + final now = DateTime.now(); + final isOverdue = widget.cotisation.dateEcheance.isBefore(now); + + _timelineEvents.add( + TimelineEvent( + title: isOverdue ? 'Échéance dépassée' : 'Échéance prévue', + description: 'Date limite de paiement: ${_formatDate(widget.cotisation.dateEcheance)}', + date: widget.cotisation.dateEcheance, + icon: isOverdue ? Icons.warning : Icons.schedule, + color: isOverdue ? AppTheme.errorColor : AppTheme.warningColor, + isCompleted: isOverdue, + isWarning: isOverdue, + ), + ); + + // Ajouter les événements de paiement (simulés) + if (widget.cotisation.montantPaye > 0) { + _timelineEvents.add( + TimelineEvent( + title: 'Paiement partiel reçu', + description: 'Montant: ${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF', + date: widget.cotisation.dateCreation.add(const Duration(days: 5)), // Simulé + icon: Icons.payment, + color: AppTheme.successColor, + isCompleted: true, + ), + ); + } + + if (widget.cotisation.isEntierementPayee) { + _timelineEvents.add( + TimelineEvent( + title: 'Paiement complet', + description: 'Cotisation entièrement payée', + date: widget.cotisation.dateCreation.add(const Duration(days: 10)), // Simulé + icon: Icons.check_circle, + color: AppTheme.successColor, + isCompleted: true, + isSuccess: true, + ), + ); + } else { + // Ajouter les événements futurs + if (!isOverdue) { + _timelineEvents.add( + TimelineEvent( + title: 'Rappel automatique', + description: 'Rappel envoyé 3 jours avant l\'échéance', + date: widget.cotisation.dateEcheance.subtract(const Duration(days: 3)), + icon: Icons.notifications, + color: AppTheme.infoColor, + isCompleted: false, + isFuture: true, + ), + ); + } + + _timelineEvents.add( + TimelineEvent( + title: 'Paiement en attente', + description: 'En attente du paiement complet', + date: DateTime.now(), + icon: Icons.hourglass_empty, + color: AppTheme.textSecondary, + isCompleted: false, + isFuture: true, + ), + ); + } + + // Trier par date + _timelineEvents.sort((a, b) => a.date.compareTo(b.date)); + } + + @override + Widget build(BuildContext context) { + if (_timelineEvents.isEmpty) { + return const Center( + child: Text( + 'Aucun historique disponible', + style: TextStyle( + fontSize: 16, + color: AppTheme.textSecondary, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Historique de la cotisation', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 20), + + Expanded( + child: ListView.builder( + itemCount: _timelineEvents.length, + itemBuilder: (context, index) { + return AnimatedBuilder( + animation: _itemAnimations[index], + builder: (context, child) { + return Transform.translate( + offset: Offset( + 0, + 50 * (1 - _itemAnimations[index].value), + ), + child: Opacity( + opacity: _itemAnimations[index].value, + child: _buildTimelineItem( + _timelineEvents[index], + index, + index == _timelineEvents.length - 1, + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildTimelineItem(TimelineEvent event, int index, bool isLast) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline indicator + Column( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: event.isCompleted + ? event.color + : event.color.withOpacity(0.2), + border: Border.all( + color: event.color, + width: event.isCompleted ? 0 : 2, + ), + ), + child: Icon( + event.icon, + size: 20, + color: event.isCompleted + ? Colors.white + : event.color, + ), + ), + if (!isLast) + Container( + width: 2, + height: 60, + color: event.isCompleted + ? event.color.withOpacity(0.3) + : AppTheme.borderLight, + ), + ], + ), + const SizedBox(width: 16), + + // Event content + Expanded( + child: Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _getEventBackgroundColor(event), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: event.color.withOpacity(0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + event.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: event.isCompleted + ? AppTheme.textPrimary + : AppTheme.textSecondary, + ), + ), + ), + if (event.isSuccess) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppTheme.successColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'Terminé', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppTheme.successColor, + ), + ), + ), + if (event.isWarning) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppTheme.errorColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'En retard', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppTheme.errorColor, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + event.description, + style: TextStyle( + fontSize: 14, + color: event.isCompleted + ? AppTheme.textSecondary + : AppTheme.textHint, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.access_time, + size: 16, + color: AppTheme.textHint, + ), + const SizedBox(width: 4), + Text( + _formatDateTime(event.date), + style: const TextStyle( + fontSize: 12, + color: AppTheme.textHint, + ), + ), + if (event.isFuture) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.infoColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'À venir', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppTheme.infoColor, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ), + ], + ); + } + + Color _getEventBackgroundColor(TimelineEvent event) { + if (event.isSuccess) { + return AppTheme.successColor.withOpacity(0.05); + } + if (event.isWarning) { + return AppTheme.errorColor.withOpacity(0.05); + } + if (event.isFuture) { + return AppTheme.infoColor.withOpacity(0.05); + } + return Colors.white; + } + + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } + + String _formatDateTime(DateTime date) { + return '${_formatDate(date)} à ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } +} + +/// Modèle pour les événements de la timeline +class TimelineEvent { + final String title; + final String description; + final DateTime date; + final IconData icon; + final Color color; + final bool isCompleted; + final bool isSuccess; + final bool isWarning; + final bool isFuture; + + TimelineEvent({ + required this.title, + required this.description, + required this.date, + required this.icon, + required this.color, + this.isCompleted = false, + this.isSuccess = false, + this.isWarning = false, + this.isFuture = false, + }); +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/payment_form_widget.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/payment_form_widget.dart new file mode 100644 index 0000000..eb840f3 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/payment_form_widget.dart @@ -0,0 +1,457 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/widgets/buttons/buttons.dart'; +import '../../../../shared/widgets/buttons/primary_button.dart'; +import 'payment_method_selector.dart'; + +/// Widget de formulaire de paiement +class PaymentFormWidget extends StatefulWidget { + final CotisationModel cotisation; + final Function(Map) onPaymentInitiated; + + const PaymentFormWidget({ + super.key, + required this.cotisation, + required this.onPaymentInitiated, + }); + + @override + State createState() => _PaymentFormWidgetState(); +} + +class _PaymentFormWidgetState extends State + with TickerProviderStateMixin { + final _formKey = GlobalKey(); + final _phoneController = TextEditingController(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _amountController = TextEditingController(); + + late final AnimationController _animationController; + late final Animation _slideAnimation; + + String? _selectedPaymentMethod; + bool _isProcessing = false; + bool _acceptTerms = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + _slideAnimation = Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + )); + + // Initialiser le montant avec le montant restant à payer + final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye; + _amountController.text = remainingAmount.toStringAsFixed(0); + + _animationController.forward(); + } + + @override + void dispose() { + _phoneController.dispose(); + _nameController.dispose(); + _emailController.dispose(); + _amountController.dispose(); + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SlideTransition( + position: _slideAnimation, + child: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Sélection de la méthode de paiement + PaymentMethodSelector( + selectedMethod: _selectedPaymentMethod, + montant: double.tryParse(_amountController.text) ?? 0, + onMethodSelected: (method) { + setState(() { + _selectedPaymentMethod = method; + }); + }, + ), + + if (_selectedPaymentMethod != null) ...[ + const SizedBox(height: 24), + _buildPaymentForm(), + ], + ], + ), + ), + ), + ); + } + + Widget _buildPaymentForm() { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Informations de paiement', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + + // Montant à payer + _buildAmountField(), + const SizedBox(height: 16), + + // Numéro de téléphone (pour Mobile Money) + if (_isMobileMoneyMethod()) ...[ + _buildPhoneField(), + const SizedBox(height: 16), + ], + + // Nom du payeur + _buildNameField(), + const SizedBox(height: 16), + + // Email (optionnel) + _buildEmailField(), + const SizedBox(height: 20), + + // Conditions d'utilisation + _buildTermsCheckbox(), + const SizedBox(height: 24), + + // Bouton de paiement + _buildPaymentButton(), + ], + ), + ); + } + + Widget _buildAmountField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Montant à payer', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _amountController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(8), + ], + decoration: InputDecoration( + hintText: 'Entrez le montant', + suffixText: 'XOF', + prefixIcon: const Icon(Icons.attach_money), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer un montant'; + } + final amount = double.tryParse(value); + if (amount == null || amount <= 0) { + return 'Montant invalide'; + } + final remaining = widget.cotisation.montantDu - widget.cotisation.montantPaye; + if (amount > remaining) { + return 'Montant supérieur au solde restant (${remaining.toStringAsFixed(0)} XOF)'; + } + return null; + }, + onChanged: (value) { + setState(() {}); // Recalculer les frais + }, + ), + ], + ); + } + + Widget _buildPhoneField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Numéro ${_getPaymentMethodName()}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _phoneController, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + decoration: InputDecoration( + hintText: 'Ex: 0123456789', + prefixIcon: const Icon(Icons.phone), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer votre numéro de téléphone'; + } + if (value.length < 8) { + return 'Numéro de téléphone invalide'; + } + if (!_validatePhoneForMethod(value)) { + return 'Ce numéro n\'est pas compatible avec ${_getPaymentMethodName()}'; + } + return null; + }, + ), + ], + ); + } + + Widget _buildNameField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Nom du payeur', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _nameController, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + hintText: 'Entrez votre nom complet', + prefixIcon: const Icon(Icons.person), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Veuillez entrer votre nom'; + } + if (value.trim().length < 2) { + return 'Nom trop court'; + } + return null; + }, + ), + ], + ); + } + + Widget _buildEmailField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Email (optionnel)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'exemple@email.com', + prefixIcon: const Icon(Icons.email), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2), + ), + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) { + return 'Email invalide'; + } + } + return null; + }, + ), + ], + ); + } + + Widget _buildTermsCheckbox() { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: _acceptTerms, + onChanged: (value) { + setState(() { + _acceptTerms = value ?? false; + }); + }, + activeColor: AppTheme.primaryColor, + ), + Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _acceptTerms = !_acceptTerms; + }); + }, + child: const Text( + 'J\'accepte les conditions d\'utilisation et la politique de confidentialité', + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + ), + ), + ], + ); + } + + Widget _buildPaymentButton() { + return SizedBox( + width: double.infinity, + child: PrimaryButton( + text: _isProcessing + ? 'Traitement en cours...' + : 'Confirmer le paiement', + icon: _isProcessing ? null : Icons.payment, + onPressed: _canProceedPayment() ? _processPayment : null, + isLoading: _isProcessing, + ), + ); + } + + bool _canProceedPayment() { + return _selectedPaymentMethod != null && + _acceptTerms && + !_isProcessing && + _amountController.text.isNotEmpty; + } + + bool _isMobileMoneyMethod() { + return _selectedPaymentMethod == 'ORANGE_MONEY' || + _selectedPaymentMethod == 'WAVE' || + _selectedPaymentMethod == 'MOOV_MONEY'; + } + + String _getPaymentMethodName() { + switch (_selectedPaymentMethod) { + case 'ORANGE_MONEY': + return 'Orange Money'; + case 'WAVE': + return 'Wave'; + case 'MOOV_MONEY': + return 'Moov Money'; + case 'CARTE_BANCAIRE': + return 'Carte bancaire'; + default: + return 'Paiement'; + } + } + + bool _validatePhoneForMethod(String phone) { + final cleanNumber = phone.replaceAll(RegExp(r'[^\d]'), ''); + + switch (_selectedPaymentMethod) { + case 'ORANGE_MONEY': + // Orange: 07, 08, 09 + 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; + } + } + + void _processPayment() { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isProcessing = true; + }); + + // Préparer les données de paiement + final paymentData = { + 'montant': double.parse(_amountController.text), + 'methodePaiement': _selectedPaymentMethod!, + 'numeroTelephone': _phoneController.text, + 'nomPayeur': _nameController.text.trim(), + 'emailPayeur': _emailController.text.trim().isEmpty + ? null + : _emailController.text.trim(), + }; + + // Déclencher le paiement + widget.onPaymentInitiated(paymentData); + + // Simuler un délai de traitement + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _isProcessing = false; + }); + } + }); + } +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/payment_method_selector.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/payment_method_selector.dart new file mode 100644 index 0000000..4f56555 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/payment_method_selector.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; +import '../../../../core/services/payment_service.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Widget de sélection des méthodes de paiement +class PaymentMethodSelector extends StatefulWidget { + final String? selectedMethod; + final Function(String) onMethodSelected; + final double montant; + + const PaymentMethodSelector({ + super.key, + this.selectedMethod, + required this.onMethodSelected, + required this.montant, + }); + + @override + State createState() => _PaymentMethodSelectorState(); +} + +class _PaymentMethodSelectorState extends State + with TickerProviderStateMixin { + late final AnimationController _animationController; + late final Animation _scaleAnimation; + + List _paymentMethods = []; + String? _selectedMethod; + + @override + void initState() { + super.initState(); + _selectedMethod = widget.selectedMethod; + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _scaleAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.elasticOut), + ); + + _loadPaymentMethods(); + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _loadPaymentMethods() { + // En production, ceci viendrait du PaymentService + _paymentMethods = [ + 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, + ), + ]; + + // Filtrer les méthodes disponibles selon le montant + _paymentMethods = _paymentMethods.where((method) { + return widget.montant >= method.montantMinimum && + widget.montant <= method.montantMaximum; + }).toList(); + + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition( + scale: _scaleAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Choisissez votre méthode de paiement', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + + if (_paymentMethods.isEmpty) + _buildNoMethodsAvailable() + else + _buildMethodsList(), + + if (_selectedMethod != null) ...[ + const SizedBox(height: 20), + _buildSelectedMethodInfo(), + ], + ], + ), + ); + } + + Widget _buildNoMethodsAvailable() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.warningColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.warningColor.withOpacity(0.3), + ), + ), + child: Column( + children: [ + Icon( + Icons.warning_amber, + size: 48, + color: AppTheme.warningColor, + ), + const SizedBox(height: 12), + const Text( + 'Aucune méthode de paiement disponible', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Le montant de ${widget.montant.toStringAsFixed(0)} XOF ne correspond aux limites d\'aucune méthode de paiement.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildMethodsList() { + return Column( + children: _paymentMethods.map((method) { + final isSelected = _selectedMethod == method.id; + final fees = _calculateFees(method); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _selectMethod(method), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? _getMethodColor(method.couleur).withOpacity(0.1) + : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? _getMethodColor(method.couleur) + : AppTheme.borderLight, + width: isSelected ? 2 : 1, + ), + boxShadow: isSelected ? [ + BoxShadow( + color: _getMethodColor(method.couleur).withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] : null, + ), + child: Row( + children: [ + // Icône de la méthode + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: _getMethodColor(method.couleur).withOpacity(0.1), + borderRadius: BorderRadius.circular(25), + ), + child: Center( + child: Text( + method.icone, + style: const TextStyle(fontSize: 24), + ), + ), + ), + const SizedBox(width: 16), + + // Informations de la méthode + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + method.nom, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isSelected + ? _getMethodColor(method.couleur) + : AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + method.description, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + if (fees > 0) ...[ + const SizedBox(height: 4), + Text( + 'Frais: ${fees.toStringAsFixed(0)} XOF', + style: TextStyle( + fontSize: 12, + color: AppTheme.warningColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + + // Indicateur de sélection + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? _getMethodColor(method.couleur) + : Colors.transparent, + border: Border.all( + color: isSelected + ? _getMethodColor(method.couleur) + : AppTheme.borderLight, + width: 2, + ), + ), + child: isSelected + ? const Icon( + Icons.check, + size: 16, + color: Colors.white, + ) + : null, + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ); + } + + Widget _buildSelectedMethodInfo() { + final method = _paymentMethods.firstWhere((m) => m.id == _selectedMethod); + final fees = _calculateFees(method); + final total = widget.montant + fees; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _getMethodColor(method.couleur).withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getMethodColor(method.couleur).withOpacity(0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + method.icone, + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Text( + 'Récapitulatif - ${method.nom}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _getMethodColor(method.couleur), + ), + ), + ], + ), + const SizedBox(height: 12), + + _buildSummaryRow('Montant', '${widget.montant.toStringAsFixed(0)} XOF'), + if (fees > 0) + _buildSummaryRow('Frais', '${fees.toStringAsFixed(0)} XOF'), + const Divider(), + _buildSummaryRow( + 'Total à payer', + '${total.toStringAsFixed(0)} XOF', + isTotal: true, + ), + ], + ), + ); + } + + Widget _buildSummaryRow(String label, String value, {bool isTotal = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: isTotal ? 16 : 14, + fontWeight: isTotal ? FontWeight.bold : FontWeight.normal, + color: AppTheme.textSecondary, + ), + ), + Text( + value, + style: TextStyle( + fontSize: isTotal ? 16 : 14, + fontWeight: FontWeight.bold, + color: isTotal ? AppTheme.textPrimary : AppTheme.textSecondary, + ), + ), + ], + ), + ); + } + + void _selectMethod(PaymentMethod method) { + setState(() { + _selectedMethod = method.id; + }); + widget.onMethodSelected(method.id); + + // Animation de feedback + _animationController.reset(); + _animationController.forward(); + } + + double _calculateFees(PaymentMethod method) { + // Simulation du calcul des frais + switch (method.id) { + case 'ORANGE_MONEY': + return _calculateOrangeMoneyFees(widget.montant); + case 'WAVE': + return _calculateWaveFees(widget.montant); + case 'MOOV_MONEY': + return _calculateMoovMoneyFees(widget.montant); + case 'CARTE_BANCAIRE': + return _calculateCardFees(widget.montant); + default: + return 0.0; + } + } + + 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) { + 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) { + return 100 + (montant * 0.025); // 100 XOF + 2.5% + } + + Color _getMethodColor(String colorHex) { + return Color(int.parse(colorHex.replaceFirst('#', '0xFF'))); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page.dart index c7932d9..e979848 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import '../../../../shared/theme/app_theme.dart'; +import '../../../../core/animations/page_transitions.dart'; +import '../../../demo/presentation/pages/animations_demo_page.dart'; +import '../../../debug/debug_api_test_page.dart'; // Imports des nouveaux widgets refactorisés import '../widgets/welcome/welcome_section_widget.dart'; @@ -31,12 +34,30 @@ class DashboardPage extends StatelessWidget { backgroundColor: AppTheme.primaryColor, elevation: 0, actions: [ + IconButton( + icon: const Icon(Icons.animation), + onPressed: () { + Navigator.of(context).push( + PageTransitions.morphWithBlur(const AnimationsDemoPage()), + ); + }, + tooltip: 'Démonstration des animations', + ), IconButton( icon: const Icon(Icons.notifications_outlined), onPressed: () { // TODO: Implémenter la navigation vers les notifications }, ), + IconButton( + icon: const Icon(Icons.bug_report), + onPressed: () { + Navigator.of(context).push( + PageTransitions.slideFromRight(const DebugApiTestPage()), + ); + }, + tooltip: 'Debug API', + ), IconButton( icon: const Icon(Icons.settings_outlined), onPressed: () { diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/recent_activities_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/recent_activities_widget.dart index 847046f..b2e84e5 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/recent_activities_widget.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/recent_activities_widget.dart @@ -86,17 +86,17 @@ class RecentActivitiesWidget extends StatelessWidget { ), ], ), - child: Column( + child: const Column( children: [ ActivityItemWidget( title: 'Paiement Mobile Money reçu', description: 'Kouassi Yao - 25,000 FCFA via Orange Money', icon: Icons.phone_android, - color: const Color(0xFFFF9800), + color: Color(0xFFFF9800), time: 'Il y a 3 min', isNew: true, ), - const Divider(height: 1), + Divider(height: 1), ActivityItemWidget( title: 'Nouveau membre validé', description: 'Adjoua Marie inscrite depuis Abidjan', @@ -105,7 +105,7 @@ class RecentActivitiesWidget extends StatelessWidget { time: 'Il y a 15 min', isNew: true, ), - const Divider(height: 1), + Divider(height: 1), ActivityItemWidget( title: 'Relance automatique envoyée', description: '12 SMS de rappel cotisations expédiés', @@ -113,15 +113,15 @@ class RecentActivitiesWidget extends StatelessWidget { color: AppTheme.infoColor, time: 'Il y a 1h', ), - const Divider(height: 1), + Divider(height: 1), ActivityItemWidget( title: 'Rapport OHADA généré', description: 'Bilan financier T4 2024 exporté', icon: Icons.description, - color: const Color(0xFF795548), + color: Color(0xFF795548), time: 'Il y a 2h', ), - const Divider(height: 1), + Divider(height: 1), ActivityItemWidget( title: 'Événement: Forte participation', description: 'AG Extraordinaire - 89% de présence', @@ -129,7 +129,7 @@ class RecentActivitiesWidget extends StatelessWidget { color: AppTheme.successColor, time: 'Il y a 3h', ), - const Divider(height: 1), + Divider(height: 1), ActivityItemWidget( title: 'Alerte: Cotisations en retard', description: '23 membres avec +30 jours de retard', @@ -137,7 +137,7 @@ class RecentActivitiesWidget extends StatelessWidget { color: AppTheme.warningColor, time: 'Il y a 4h', ), - const Divider(height: 1), + Divider(height: 1), ActivityItemWidget( title: 'Synchronisation réussie', description: 'Données sauvegardées sur le cloud', @@ -145,12 +145,12 @@ class RecentActivitiesWidget extends StatelessWidget { color: AppTheme.successColor, time: 'Il y a 6h', ), - const Divider(height: 1), + Divider(height: 1), ActivityItemWidget( title: 'Message diffusé', description: 'Info COVID-19 envoyée à 1,247 membres', icon: Icons.campaign, - color: const Color(0xFF9C27B0), + color: Color(0xFF9C27B0), time: 'Hier 18:30', ), ], diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/charts/charts_analytics_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/charts/charts_analytics_widget.dart index f106a48..367e712 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/charts/charts_analytics_widget.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/charts/charts_analytics_widget.dart @@ -86,11 +86,11 @@ class ChartsAnalyticsWidget extends StatelessWidget { ), ), const SizedBox(width: 8), - Expanded( + const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Évolution des membres actifs', style: TextStyle( fontSize: 16, @@ -98,8 +98,8 @@ class ChartsAnalyticsWidget extends StatelessWidget { color: AppTheme.textPrimary, ), ), - const SizedBox(height: 2), - const Text( + SizedBox(height: 2), + Text( 'Croissance sur 5 mois • +24.7% (+247 membres)', style: TextStyle( fontSize: 11, diff --git a/unionflow-mobile-apps/lib/features/debug/debug_api_test_page.dart b/unionflow-mobile-apps/lib/features/debug/debug_api_test_page.dart new file mode 100644 index 0000000..bbdf1ac --- /dev/null +++ b/unionflow-mobile-apps/lib/features/debug/debug_api_test_page.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import '../../core/services/api_service.dart'; +import '../../core/di/injection.dart'; +import '../../shared/theme/app_theme.dart'; + +/// Page de test pour diagnostiquer les problèmes d'API +class DebugApiTestPage extends StatefulWidget { + const DebugApiTestPage({super.key}); + + @override + State createState() => _DebugApiTestPageState(); +} + +class _DebugApiTestPageState extends State { + final ApiService _apiService = getIt(); + String _result = 'Aucun test effectué'; + bool _isLoading = false; + + Future _testEvenementsAPI() async { + setState(() { + _isLoading = true; + _result = 'Test en cours...'; + }); + + try { + print('🧪 Début du test API événements'); + final evenements = await _apiService.getEvenementsAVenir(); + + setState(() { + _result = '''✅ SUCCÈS ! +Nombre d'événements récupérés: ${evenements.length} + +Détails des événements: +${evenements.map((e) => '• ${e.titre} (${e.typeEvenement})').join('\n')} +'''; + _isLoading = false; + }); + + print('🎉 Test réussi: ${evenements.length} événements'); + } catch (e) { + setState(() { + _result = '''❌ ERREUR ! +Type d'erreur: ${e.runtimeType} +Message: $e + +Vérifiez: +1. Le serveur backend est-il démarré ? +2. L'URL est-elle correcte ? +3. Le réseau est-il accessible ? +'''; + _isLoading = false; + }); + + print('💥 Test échoué: $e'); + } + } + + Future _testConnectivity() async { + setState(() { + _isLoading = true; + _result = 'Test de connectivité...'; + }); + + try { + // Test simple de connectivité via l'API service + final evenements = await _apiService.getEvenementsAVenir(size: 1); + + setState(() { + _result = '''✅ CONNECTIVITÉ OK ! +Connexion au serveur réussie. +Nombre d'événements de test: ${evenements.length} +'''; + _isLoading = false; + }); + } catch (e) { + setState(() { + _result = '''❌ PROBLÈME DE CONNECTIVITÉ ! +Erreur: $e + +Le serveur backend n'est pas accessible. +Vérifiez que le serveur Quarkus est démarré sur 192.168.1.145:8080 +'''; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Debug API Test'), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tests de Diagnostic', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + ElevatedButton.icon( + onPressed: _isLoading ? null : _testConnectivity, + icon: const Icon(Icons.network_check), + label: const Text('Test Connectivité'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + + const SizedBox(height: 8), + + ElevatedButton.icon( + onPressed: _isLoading ? null : _testEvenementsAPI, + icon: const Icon(Icons.event), + label: const Text('Test API Événements'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.successColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Résultats', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (_isLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + + const SizedBox(height: 16), + + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: SingleChildScrollView( + child: Text( + _result, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + + const SizedBox(height: 16), + + Card( + color: Colors.blue[50], + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info, color: Colors.blue[700]), + const SizedBox(width: 8), + Text( + 'Informations de Configuration', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.blue[700], + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'URL Backend: http://192.168.1.145:8080\n' + 'Endpoint: /api/evenements/a-venir-public\n' + 'Méthode: GET', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/demo/presentation/pages/animations_demo_page.dart b/unionflow-mobile-apps/lib/features/demo/presentation/pages/animations_demo_page.dart new file mode 100644 index 0000000..69fefd6 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/demo/presentation/pages/animations_demo_page.dart @@ -0,0 +1,464 @@ +import 'package:flutter/material.dart'; +import '../../../../core/animations/animated_button.dart'; +import '../../../../core/animations/animated_notifications.dart'; +import '../../../../core/animations/page_transitions.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Page de démonstration des animations +class AnimationsDemoPage extends StatefulWidget { + const AnimationsDemoPage({super.key}); + + @override + State createState() => _AnimationsDemoPageState(); +} + +class _AnimationsDemoPageState extends State + with TickerProviderStateMixin { + late AnimationController _floatingController; + late AnimationController _pulseController; + late Animation _floatingAnimation; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + + _floatingController = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + )..repeat(reverse: true); + + _pulseController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(); + + _floatingAnimation = Tween( + begin: -10.0, + end: 10.0, + ).animate(CurvedAnimation( + parent: _floatingController, + curve: Curves.easeInOut, + )); + + _pulseAnimation = Tween( + begin: 1.0, + end: 1.2, + ).animate(CurvedAnimation( + parent: _pulseController, + curve: Curves.elasticOut, + )); + } + + @override + void dispose() { + _floatingController.dispose(); + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Démonstration des Animations'), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Section Boutons Animés + _buildSection( + 'Boutons Animés', + [ + const SizedBox(height: 16), + AnimatedButton( + text: 'Bouton Principal', + onPressed: () => _showNotification(NotificationType.success), + style: AnimatedButtonStyle.primary, + ), + const SizedBox(height: 12), + AnimatedButton( + text: 'Bouton Secondaire', + onPressed: () => _showNotification(NotificationType.info), + style: AnimatedButtonStyle.secondary, + ), + const SizedBox(height: 12), + AnimatedButton( + text: 'Bouton de Succès', + onPressed: () => _showNotification(NotificationType.success), + style: AnimatedButtonStyle.success, + ), + const SizedBox(height: 12), + AnimatedButton( + text: 'Bouton d\'Avertissement', + onPressed: () => _showNotification(NotificationType.warning), + style: AnimatedButtonStyle.warning, + ), + const SizedBox(height: 12), + AnimatedButton( + text: 'Bouton d\'Erreur', + onPressed: () => _showNotification(NotificationType.error), + style: AnimatedButtonStyle.error, + ), + const SizedBox(height: 12), + AnimatedButton( + text: 'Bouton Contour', + onPressed: () => _showNotification(NotificationType.info), + style: AnimatedButtonStyle.outline, + ), + ], + ), + + const SizedBox(height: 32), + + // Section Notifications + _buildSection( + 'Notifications Animées', + [ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showNotification(NotificationType.success), + icon: const Icon(Icons.check_circle), + label: const Text('Succès'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.successColor, + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showNotification(NotificationType.error), + icon: const Icon(Icons.error), + label: const Text('Erreur'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.errorColor, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showNotification(NotificationType.warning), + icon: const Icon(Icons.warning), + label: const Text('Avertissement'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.warningColor, + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showNotification(NotificationType.info), + icon: const Icon(Icons.info), + label: const Text('Information'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ], + ), + + const SizedBox(height: 32), + + // Section Transitions de Page + _buildSection( + 'Transitions de Page', + [ + const SizedBox(height: 16), + _buildTransitionButton( + 'Glissement depuis la droite', + () => _navigateWithTransition(PageTransitions.slideFromRight), + ), + const SizedBox(height: 8), + _buildTransitionButton( + 'Glissement depuis le bas', + () => _navigateWithTransition(PageTransitions.slideFromBottom), + ), + const SizedBox(height: 8), + _buildTransitionButton( + 'Fondu', + () => _navigateWithTransition(PageTransitions.fadeIn), + ), + const SizedBox(height: 8), + _buildTransitionButton( + 'Échelle avec fondu', + () => _navigateWithTransition(PageTransitions.scaleWithFade), + ), + const SizedBox(height: 8), + _buildTransitionButton( + 'Rebond', + () => _navigateWithTransition(PageTransitions.bounceIn), + ), + const SizedBox(height: 8), + _buildTransitionButton( + 'Parallaxe', + () => _navigateWithTransition(PageTransitions.slideWithParallax), + ), + const SizedBox(height: 8), + _buildTransitionButton( + 'Morphing avec Blur', + () => _navigateWithTransition(PageTransitions.morphWithBlur), + ), + const SizedBox(height: 8), + _buildTransitionButton( + 'Rotation 3D', + () => _navigateWithTransition(PageTransitions.rotate3D), + ), + ], + ), + + const SizedBox(height: 32), + + // Section Animations Continues + _buildSection( + 'Animations Continues', + [ + const SizedBox(height: 16), + Center( + child: Column( + children: [ + AnimatedBuilder( + animation: _floatingAnimation, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, _floatingAnimation.value), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.primaryColor, + AppTheme.primaryColor.withOpacity(0.7), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(40), + boxShadow: [ + BoxShadow( + color: AppTheme.primaryColor.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.star, + color: Colors.white, + size: 40, + ), + ), + ); + }, + ), + const SizedBox(height: 16), + const Text( + 'Animation Flottante', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 32), + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: AppTheme.successColor, + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + color: AppTheme.successColor.withOpacity(0.4), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: const Icon( + Icons.favorite, + color: Colors.white, + size: 30, + ), + ), + ); + }, + ), + const SizedBox(height: 16), + const Text( + 'Animation Pulsante', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildSection(String title, List children) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + const Divider(height: 24), + ...children, + ], + ), + ), + ); + } + + Widget _buildTransitionButton(String text, VoidCallback onPressed) { + return SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + side: const BorderSide(color: AppTheme.primaryColor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + text, + style: const TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + void _showNotification(NotificationType type) { + switch (type) { + case NotificationType.success: + AnimatedNotifications.showSuccess( + context, + 'Opération réussie avec succès !', + ); + break; + case NotificationType.error: + AnimatedNotifications.showError( + context, + 'Une erreur s\'est produite lors de l\'opération.', + ); + break; + case NotificationType.warning: + AnimatedNotifications.showWarning( + context, + 'Attention : cette action nécessite une confirmation.', + ); + break; + case NotificationType.info: + AnimatedNotifications.showInfo( + context, + 'Information : les données ont été mises à jour.', + ); + break; + } + } + + void _navigateWithTransition(PageRouteBuilder Function(Widget) transitionBuilder) { + Navigator.of(context).push( + transitionBuilder(const _DemoDestinationPage()), + ); + } +} + +/// Page de destination pour les démonstrations de transition +class _DemoDestinationPage extends StatelessWidget { + const _DemoDestinationPage(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Page de Destination'), + backgroundColor: AppTheme.secondaryColor, + foregroundColor: Colors.white, + ), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle, + size: 80, + color: AppTheme.successColor, + ), + SizedBox(height: 24), + Text( + 'Transition réussie !', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + SizedBox(height: 16), + Text( + 'Vous pouvez revenir en arrière\npour tester d\'autres transitions.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/evenements/presentation/pages/evenements_page.dart b/unionflow-mobile-apps/lib/features/evenements/presentation/pages/evenements_page.dart index c75d04b..ed26a5f 100644 --- a/unionflow-mobile-apps/lib/features/evenements/presentation/pages/evenements_page.dart +++ b/unionflow-mobile-apps/lib/features/evenements/presentation/pages/evenements_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/models/evenement_model.dart'; +import '../../../../core/animations/loading_animations.dart'; +import '../../../../core/animations/page_transitions.dart'; import '../../../../shared/theme/app_theme.dart'; import '../bloc/evenement_bloc.dart'; import '../bloc/evenement_event.dart'; @@ -9,6 +11,7 @@ import '../bloc/evenement_state.dart'; import '../widgets/evenement_card.dart'; import '../widgets/evenement_search_bar.dart'; import '../widgets/evenement_filter_chips.dart'; +import '../widgets/animated_evenement_list.dart'; import 'evenement_detail_page.dart'; import 'evenement_create_page.dart'; @@ -36,6 +39,9 @@ class _EvenementsPageContent extends StatefulWidget { class _EvenementsPageContentState extends State<_EvenementsPageContent> with TickerProviderStateMixin { late TabController _tabController; + late AnimationController _listAnimationController; + late AnimationController _tabAnimationController; + late Animation _tabFadeAnimation; final ScrollController _scrollController = ScrollController(); String _searchTerm = ''; TypeEvenement? _selectedType; @@ -44,18 +50,40 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent> void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); + _listAnimationController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _tabAnimationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _tabFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _tabAnimationController, + curve: Curves.easeInOut, + ), + ); _scrollController.addListener(_onScroll); - + _tabController.addListener(() { if (_tabController.indexIsChanging) { _onTabChanged(_tabController.index); } }); + + // Démarrer les animations d'entrée + _listAnimationController.forward(); + _tabAnimationController.forward(); } @override void dispose() { _tabController.dispose(); + _listAnimationController.dispose(); + _tabAnimationController.dispose(); _scrollController.dispose(); super.dispose(); } @@ -192,8 +220,8 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent> void _navigateToDetail(EvenementModel evenement) { Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => EvenementDetailPage(evenement: evenement), + PageTransitions.slideFromRight( + EvenementDetailPage(evenement: evenement), ), ); } @@ -214,28 +242,42 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent> ], ), ), - body: TabBarView( - controller: _tabController, - children: [ - _buildEvenementsList(showSearch: false), - _buildEvenementsList(showSearch: false), - _buildEvenementsList(showSearch: true), - ], + body: FadeTransition( + opacity: _tabFadeAnimation, + child: TabBarView( + controller: _tabController, + children: [ + _buildEvenementsList(showSearch: false), + _buildEvenementsList(showSearch: false), + _buildEvenementsList(showSearch: true), + ], + ), ), - floatingActionButton: FloatingActionButton( - onPressed: () async { - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const EvenementCreatePage(), + floatingActionButton: AnimatedBuilder( + animation: _listAnimationController, + builder: (context, child) { + return Transform.scale( + scale: 0.8 + (0.2 * _listAnimationController.value), + child: FloatingActionButton.extended( + onPressed: () async { + final result = await Navigator.of(context).push( + PageTransitions.slideFromBottom( + const EvenementCreatePage(), + ), + ); + + // Si un événement a été créé, recharger la liste + if (result == true && context.mounted) { + context.read().add(const LoadEvenementsAVenir()); + } + }, + icon: const Icon(Icons.add), + label: const Text('Nouvel événement'), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, ), ); - - // Si un événement a été créé, recharger la liste - if (result == true && context.mounted) { - context.read().add(const LoadEvenementsAVenir()); - } }, - child: const Icon(Icons.add), ), ); } @@ -278,7 +320,7 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent> child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, size: 64, color: Colors.red), + const Icon(Icons.error_outline, size: 64, color: Colors.red), const SizedBox(height: 16), Text(state.message, textAlign: TextAlign.center), const SizedBox(height: 16), @@ -333,45 +375,21 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent> ); } - final evenements = state is EvenementLoaded + final evenements = state is EvenementLoaded ? state.evenements : state is EvenementLoadingMore ? state.evenements : state is EvenementError ? state.evenements ?? [] : []; - - if (evenements.isEmpty) { - return const Center( - child: Text('Aucun événement disponible'), - ); - } - - return RefreshIndicator( - onRefresh: () async => _onRefresh(), - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: evenements.length + - (state is EvenementLoadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= evenements.length) { - return const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ); - } - - final evenement = evenements[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: EvenementCard( - evenement: evenement, - onTap: () => _navigateToDetail(evenement), - ), - ); - }, - ), + + final isLoadingMore = state is EvenementLoadingMore; + + return AnimatedEvenementList( + evenements: evenements, + isLoading: isLoadingMore, + onEvenementTap: _navigateToDetail, + onRefresh: _onRefresh, ); }, ), diff --git a/unionflow-mobile-apps/lib/features/evenements/presentation/widgets/animated_evenement_card.dart b/unionflow-mobile-apps/lib/features/evenements/presentation/widgets/animated_evenement_card.dart new file mode 100644 index 0000000..bb02f8c --- /dev/null +++ b/unionflow-mobile-apps/lib/features/evenements/presentation/widgets/animated_evenement_card.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/models/evenement_model.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Carte d'événement avec animations sophistiquées +class AnimatedEvenementCard extends StatefulWidget { + final EvenementModel evenement; + final VoidCallback? onTap; + final VoidCallback? onFavorite; + final bool showActions; + + const AnimatedEvenementCard({ + super.key, + required this.evenement, + this.onTap, + this.onFavorite, + this.showActions = true, + }); + + @override + State createState() => _AnimatedEvenementCardState(); +} + +class _AnimatedEvenementCardState extends State + with TickerProviderStateMixin { + late AnimationController _hoverController; + late AnimationController _tapController; + late AnimationController _favoriteController; + + late Animation _scaleAnimation; + late Animation _elevationAnimation; + late Animation _favoriteScaleAnimation; + late Animation _favoriteColorAnimation; + + bool _isHovered = false; + bool _isFavorite = false; + + @override + void initState() { + super.initState(); + + _hoverController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + + _tapController = AnimationController( + duration: const Duration(milliseconds: 100), + vsync: this, + ); + + _favoriteController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: 1.02, + ).animate(CurvedAnimation( + parent: _hoverController, + curve: Curves.easeOutCubic, + )); + + _elevationAnimation = Tween( + begin: 2.0, + end: 8.0, + ).animate(CurvedAnimation( + parent: _hoverController, + curve: Curves.easeOutCubic, + )); + + _favoriteScaleAnimation = Tween( + begin: 1.0, + end: 1.3, + ).animate(CurvedAnimation( + parent: _favoriteController, + curve: Curves.elasticOut, + )); + + _favoriteColorAnimation = ColorTween( + begin: Colors.grey[400], + end: Colors.red, + ).animate(CurvedAnimation( + parent: _favoriteController, + curve: Curves.easeInOut, + )); + } + + @override + void dispose() { + _hoverController.dispose(); + _tapController.dispose(); + _favoriteController.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails details) { + _tapController.forward(); + } + + void _onTapUp(TapUpDetails details) { + _tapController.reverse(); + } + + void _onTapCancel() { + _tapController.reverse(); + } + + void _onHover(bool isHovered) { + setState(() => _isHovered = isHovered); + if (isHovered) { + _hoverController.forward(); + } else { + _hoverController.reverse(); + } + } + + void _onFavoriteToggle() { + setState(() => _isFavorite = !_isFavorite); + if (_isFavorite) { + _favoriteController.forward(); + } else { + _favoriteController.reverse(); + } + widget.onFavorite?.call(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dateFormat = DateFormat('dd/MM/yyyy'); + final timeFormat = DateFormat('HH:mm'); + + return AnimatedBuilder( + animation: Listenable.merge([ + _scaleAnimation, + _elevationAnimation, + _favoriteScaleAnimation, + _favoriteColorAnimation, + ]), + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: MouseRegion( + onEnter: (_) => _onHover(true), + onExit: (_) => _onHover(false), + child: Card( + elevation: _elevationAnimation.value, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: _isHovered + ? LinearGradient( + colors: [ + Colors.white, + AppTheme.primaryColor.withOpacity(0.02), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + ), + child: InkWell( + onTap: widget.onTap, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête avec type et actions + Row( + children: [ + // Icône du type avec animation + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _isHovered + ? AppTheme.primaryColor.withOpacity(0.15) + : AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + widget.evenement.typeEvenement.icone, + style: const TextStyle(fontSize: 24), + ), + ), + + const SizedBox(width: 12), + + // Type et statut + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.evenement.typeEvenement.libelle, + style: theme.textTheme.bodySmall?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + _buildStatusChip(), + ], + ), + ), + + // Bouton favori animé + if (widget.showActions) + GestureDetector( + onTap: _onFavoriteToggle, + child: Transform.scale( + scale: _favoriteScaleAnimation.value, + child: Icon( + _isFavorite ? Icons.favorite : Icons.favorite_border, + color: _favoriteColorAnimation.value, + size: 24, + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Titre avec animation de couleur + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: _isHovered + ? AppTheme.primaryColor + : theme.textTheme.titleLarge?.color, + ) ?? const TextStyle(), + child: Text(widget.evenement.titre), + ), + + if (widget.evenement.description?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + Text( + widget.evenement.description!, + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + + const SizedBox(height: 16), + + // Informations de date et lieu avec icônes animées + Row( + children: [ + _buildAnimatedInfo( + icon: Icons.calendar_today, + text: dateFormat.format(widget.evenement.dateDebut), + ), + const SizedBox(width: 16), + _buildAnimatedInfo( + icon: Icons.access_time, + text: timeFormat.format(widget.evenement.dateDebut), + ), + ], + ), + + if (widget.evenement.lieu?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + _buildAnimatedInfo( + icon: Icons.location_on, + text: widget.evenement.lieu!, + ), + ], + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildStatusChip() { + Color statusColor; + switch (widget.evenement.statut) { + case StatutEvenement.planifie: + statusColor = Colors.orange; + break; + case StatutEvenement.confirme: + statusColor = Colors.green; + break; + case StatutEvenement.enCours: + statusColor = Colors.blue; + break; + case StatutEvenement.termine: + statusColor = Colors.grey; + break; + case StatutEvenement.annule: + statusColor = Colors.red; + break; + case StatutEvenement.reporte: + statusColor = Colors.purple; + break; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: statusColor.withOpacity(0.3)), + ), + child: Text( + widget.evenement.statut.libelle, + style: TextStyle( + color: statusColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildAnimatedInfo({required IconData icon, required String text}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: Icon( + icon, + size: 16, + color: _isHovered + ? AppTheme.primaryColor + : Colors.grey[600], + ), + ), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/evenements/presentation/widgets/animated_evenement_list.dart b/unionflow-mobile-apps/lib/features/evenements/presentation/widgets/animated_evenement_list.dart new file mode 100644 index 0000000..820f43b --- /dev/null +++ b/unionflow-mobile-apps/lib/features/evenements/presentation/widgets/animated_evenement_list.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import '../../../../core/models/evenement_model.dart'; +import '../../../../core/animations/loading_animations.dart'; +import 'evenement_card.dart'; +import 'animated_evenement_card.dart'; + +/// Widget animé pour afficher une liste d'événements avec animations d'apparition +class AnimatedEvenementList extends StatefulWidget { + final List evenements; + final Function(EvenementModel)? onEvenementTap; + final bool isLoading; + final VoidCallback? onRefresh; + + const AnimatedEvenementList({ + super.key, + required this.evenements, + this.onEvenementTap, + this.isLoading = false, + this.onRefresh, + }); + + @override + State createState() => _AnimatedEvenementListState(); +} + +class _AnimatedEvenementListState extends State + with TickerProviderStateMixin { + late AnimationController _listController; + List _itemControllers = []; + List> _itemAnimations = []; + List> _slideAnimations = []; + + @override + void initState() { + super.initState(); + _initializeAnimations(); + } + + @override + void didUpdateWidget(AnimatedEvenementList oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.evenements.length != oldWidget.evenements.length) { + _updateAnimations(); + } + } + + @override + void dispose() { + _listController.dispose(); + for (final controller in _itemControllers) { + controller.dispose(); + } + super.dispose(); + } + + void _initializeAnimations() { + _listController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + _updateAnimations(); + _listController.forward(); + } + + void _updateAnimations() { + // Dispose des anciens controllers s'ils existent + if (_itemControllers.isNotEmpty) { + for (final controller in _itemControllers) { + controller.dispose(); + } + } + + // Créer de nouveaux controllers pour chaque élément + _itemControllers = List.generate( + widget.evenements.length, + (index) => AnimationController( + duration: Duration(milliseconds: 300 + (index * 100)), + vsync: this, + ), + ); + + // Animations de fade et scale + _itemAnimations = _itemControllers.map((controller) { + return Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeOutCubic, + ), + ); + }).toList(); + + // Animations de slide depuis le bas + _slideAnimations = _itemControllers.map((controller) { + return Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeOutCubic, + ), + ); + }).toList(); + + // Démarrer les animations avec un délai progressif + for (int i = 0; i < _itemControllers.length; i++) { + Future.delayed(Duration(milliseconds: i * 150), () { + if (mounted) { + _itemControllers[i].forward(); + } + }); + } + } + + @override + Widget build(BuildContext context) { + if (widget.isLoading && widget.evenements.isEmpty) { + return _buildLoadingState(); + } + + if (widget.evenements.isEmpty) { + return _buildEmptyState(); + } + + return RefreshIndicator( + onRefresh: () async { + widget.onRefresh?.call(); + await Future.delayed(const Duration(milliseconds: 500)); + }, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: widget.evenements.length + (widget.isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= widget.evenements.length) { + return _buildLoadingIndicator(); + } + + return _buildAnimatedItem(index); + }, + ), + ); + } + + Widget _buildAnimatedItem(int index) { + final evenement = widget.evenements[index]; + + if (index >= _itemAnimations.length) { + // Fallback pour les nouveaux éléments + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: AnimatedEvenementCard( + evenement: evenement, + onTap: () => widget.onEvenementTap?.call(evenement), + ), + ); + } + + return AnimatedBuilder( + animation: _itemAnimations[index], + builder: (context, child) { + return SlideTransition( + position: _slideAnimations[index], + child: FadeTransition( + opacity: _itemAnimations[index], + child: Transform.scale( + scale: 0.8 + (0.2 * _itemAnimations[index].value), + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: AnimatedEvenementCard( + evenement: evenement, + onTap: () => widget.onEvenementTap?.call(evenement), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildLoadingState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LoadingAnimations.waves(), + const SizedBox(height: 24), + const Text( + 'Chargement des événements...', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.event_busy, + size: 80, + color: Colors.grey[400], + ), + const SizedBox(height: 24), + Text( + 'Aucun événement trouvé', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Les événements apparaîtront ici', + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + Widget _buildLoadingIndicator() { + return Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: LoadingAnimations.dots(), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/members_list_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/members_list_page.dart index adac54f..9ef1eb1 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/pages/members_list_page.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/members_list_page.dart @@ -199,11 +199,11 @@ class _MembersListPageState extends State children: [ // Titre principal quand l'AppBar est étendu if (!innerBoxIsScrolled) - Padding( - padding: const EdgeInsets.only(top: 60), + const Padding( + padding: EdgeInsets.only(top: 60), child: Text( 'Membres', - style: const TextStyle( + style: TextStyle( color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold, @@ -473,7 +473,7 @@ class _MembersListPageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.people_outline, size: 80, color: AppTheme.textHint, diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_details_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_details_page.dart index 840c8f5..efbd291 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_details_page.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_details_page.dart @@ -172,13 +172,13 @@ class _MembreDetailsPageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error, size: 64, color: AppTheme.errorColor), - SizedBox(height: 16), + const Icon(Icons.error, size: 64, color: AppTheme.errorColor), + const SizedBox(height: 16), Text(state.message), - SizedBox(height: 16), + const SizedBox(height: 16), ElevatedButton( onPressed: () => _membresBloc.add(LoadMembreById(widget.membreId)), - child: Text('Réessayer'), + child: const Text('Réessayer'), ), ], ), diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_dashboard_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_dashboard_page.dart index 6d25a9e..5e3ade4 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_dashboard_page.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_dashboard_page.dart @@ -107,13 +107,13 @@ class _MembresDashboardPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.error_outline, size: 64, color: AppTheme.errorColor, ), const SizedBox(height: 16), - Text( + const Text( 'Erreur de chargement', style: TextStyle( fontSize: 18, @@ -124,7 +124,7 @@ class _MembresDashboardPageState extends State { const SizedBox(height: 8), Text( state.message, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppTheme.textSecondary, ), diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_stat_card.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_stat_card.dart index 0acfe4d..5f96976 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_stat_card.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_stat_card.dart @@ -94,7 +94,7 @@ class _DashboardStatCardState extends State child: AnimatedContainer( duration: DesignSystem.animationFast, curve: DesignSystem.animationCurve, - padding: EdgeInsets.all(DesignSystem.spacingLg), + padding: const EdgeInsets.all(DesignSystem.spacingLg), decoration: BoxDecoration( color: AppTheme.surfaceLight, borderRadius: BorderRadius.circular(DesignSystem.radiusLg), @@ -121,12 +121,12 @@ class _DashboardStatCardState extends State if (widget.trend != null) _buildShimmer(60, 24, radius: 12), ], ), - SizedBox(height: DesignSystem.spacingMd), + const SizedBox(height: DesignSystem.spacingMd), _buildShimmer(80, 32), - SizedBox(height: DesignSystem.spacingSm), + const SizedBox(height: DesignSystem.spacingSm), _buildShimmer(120, 16), if (widget.subtitle != null) ...[ - SizedBox(height: DesignSystem.spacingXs), + const SizedBox(height: DesignSystem.spacingXs), _buildShimmer(100, 14), ], ], @@ -153,10 +153,10 @@ class _DashboardStatCardState extends State _buildHeader(), SizedBox(height: DesignSystem.goldenHeight(DesignSystem.spacingLg)), _buildValue(), - SizedBox(height: DesignSystem.spacingSm), + const SizedBox(height: DesignSystem.spacingSm), _buildTitle(), if (widget.subtitle != null) ...[ - SizedBox(height: DesignSystem.spacingXs), + const SizedBox(height: DesignSystem.spacingXs), _buildSubtitle(), ], ], @@ -202,7 +202,7 @@ class _DashboardStatCardState extends State Widget _buildTrendBadge() { return Container( - padding: EdgeInsets.symmetric( + padding: const EdgeInsets.symmetric( horizontal: DesignSystem.spacingSm, vertical: DesignSystem.spacingXs, ), @@ -222,7 +222,7 @@ class _DashboardStatCardState extends State color: _getTrendColor(), size: 14, ), - SizedBox(width: DesignSystem.spacing2xs), + const SizedBox(width: DesignSystem.spacing2xs), Text( widget.trend!, style: DesignSystem.labelSmall.copyWith( diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_cotisations_section.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_cotisations_section.dart index 2b16c46..6550698 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_cotisations_section.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_cotisations_section.dart @@ -80,15 +80,15 @@ class MembreCotisationsSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + const Row( children: [ Icon( Icons.account_balance_wallet, color: AppTheme.primaryColor, size: 24, ), - const SizedBox(width: 8), - const Text( + SizedBox(width: 8), + Text( 'Résumé des cotisations', style: TextStyle( fontSize: 18, @@ -201,8 +201,8 @@ class MembreCotisationsSection extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - child: Padding( - padding: const EdgeInsets.all(32), + child: const Padding( + padding: EdgeInsets.all(32), child: Column( children: [ Icon( @@ -210,8 +210,8 @@ class MembreCotisationsSection extends StatelessWidget { size: 48, color: AppTheme.textHint, ), - const SizedBox(height: 16), - const Text( + SizedBox(height: 16), + Text( 'Aucune cotisation', style: TextStyle( fontSize: 16, @@ -219,8 +219,8 @@ class MembreCotisationsSection extends StatelessWidget { color: AppTheme.textPrimary, ), ), - const SizedBox(height: 8), - const Text( + SizedBox(height: 8), + Text( 'Ce membre n\'a pas encore de cotisations enregistrées.', textAlign: TextAlign.center, style: TextStyle( @@ -237,15 +237,15 @@ class MembreCotisationsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + const Row( children: [ Icon( Icons.list_alt, color: AppTheme.primaryColor, size: 20, ), - const SizedBox(width: 8), - const Text( + SizedBox(width: 8), + Text( 'Historique des cotisations', style: TextStyle( fontSize: 16, diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_stats_section.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_stats_section.dart index 7c4301d..13c1e12 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_stats_section.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_stats_section.dart @@ -56,15 +56,15 @@ class MembreStatsSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + const Row( children: [ Icon( Icons.analytics, color: AppTheme.primaryColor, size: 24, ), - const SizedBox(width: 8), - const Text( + SizedBox(width: 8), + Text( 'Vue d\'ensemble', style: TextStyle( fontSize: 18, @@ -226,15 +226,15 @@ class MembreStatsSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + const Row( children: [ Icon( Icons.pie_chart, color: AppTheme.primaryColor, size: 20, ), - const SizedBox(width: 8), - const Text( + SizedBox(width: 8), + Text( 'Répartition des paiements', style: TextStyle( fontSize: 16, @@ -280,15 +280,15 @@ class MembreStatsSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + const Row( children: [ Icon( Icons.bar_chart, color: AppTheme.primaryColor, size: 20, ), - const SizedBox(width: 8), - const Text( + SizedBox(width: 8), + Text( 'Évolution des montants', style: TextStyle( fontSize: 16, @@ -363,15 +363,15 @@ class MembreStatsSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + const Row( children: [ Icon( Icons.timeline, color: AppTheme.primaryColor, size: 20, ), - const SizedBox(width: 8), - const Text( + SizedBox(width: 8), + Text( 'Chronologie', style: TextStyle( fontSize: 16, @@ -474,7 +474,7 @@ class MembreStatsSection extends StatelessWidget { padding: const EdgeInsets.all(40), child: Column( children: [ - Icon( + const Icon( Icons.bar_chart, size: 48, color: AppTheme.textHint, diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_export_dialog.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_export_dialog.dart index faea587..7db0bec 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_export_dialog.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_export_dialog.dart @@ -51,7 +51,7 @@ class _MembresExportDialogState extends State { color: AppTheme.primaryColor.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon( + child: const Icon( Icons.file_download, color: AppTheme.primaryColor, size: 24, @@ -116,15 +116,15 @@ class _MembresExportDialogState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + const Row( children: [ Icon( Icons.info_outline, color: AppTheme.primaryColor, size: 20, ), - const SizedBox(width: 8), - const Text( + SizedBox(width: 8), + Text( 'Données à exporter', style: TextStyle( fontSize: 16, diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_view_controls.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_view_controls.dart index 8b362bb..925c711 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_view_controls.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_view_controls.dart @@ -43,7 +43,7 @@ class MembresViewControls extends StatelessWidget { ), child: Text( '$totalCount membre${totalCount > 1 ? 's' : ''}', - style: TextStyle( + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: AppTheme.primaryColor, @@ -72,7 +72,7 @@ class MembresViewControls extends StatelessWidget { PopupMenuButton( initialValue: sortBy, onSelected: onSortChanged, - icon: Icon( + icon: const Icon( Icons.sort, size: 20, color: AppTheme.textSecondary, diff --git a/unionflow-mobile-apps/lib/shared/theme/app_theme.dart b/unionflow-mobile-apps/lib/shared/theme/app_theme.dart index 15ac8a1..c6804d9 100644 --- a/unionflow-mobile-apps/lib/shared/theme/app_theme.dart +++ b/unionflow-mobile-apps/lib/shared/theme/app_theme.dart @@ -30,6 +30,7 @@ class AppTheme { // Bordures et dividers static const Color borderColor = Color(0xFFE0E0E0); + static const Color borderLight = Color(0xFFF5F5F5); static const Color dividerColor = Color(0xFFBDBDBD); // Thème clair diff --git a/unionflow-mobile-apps/lib/shared/widgets/buttons/primary_button.dart b/unionflow-mobile-apps/lib/shared/widgets/buttons/primary_button.dart new file mode 100644 index 0000000..15e4a29 --- /dev/null +++ b/unionflow-mobile-apps/lib/shared/widgets/buttons/primary_button.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_theme.dart'; + +/// Widget bouton principal réutilisable +class PrimaryButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final bool isEnabled; + final IconData? icon; + final Color? backgroundColor; + final Color? textColor; + final double? width; + final double height; + final EdgeInsetsGeometry? padding; + final BorderRadius? borderRadius; + + const PrimaryButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.isEnabled = true, + this.icon, + this.backgroundColor, + this.textColor, + this.width, + this.height = 48.0, + this.padding, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final effectiveBackgroundColor = backgroundColor ?? AppTheme.primaryColor; + final effectiveTextColor = textColor ?? Colors.white; + final isButtonEnabled = isEnabled && !isLoading && onPressed != null; + + return SizedBox( + width: width, + height: height, + child: ElevatedButton( + onPressed: isButtonEnabled ? onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveTextColor, + disabledBackgroundColor: effectiveBackgroundColor.withOpacity(0.5), + disabledForegroundColor: effectiveTextColor.withOpacity(0.5), + elevation: isButtonEnabled ? 2 : 0, + shadowColor: effectiveBackgroundColor.withOpacity(0.3), + shape: RoundedRectangleBorder( + borderRadius: borderRadius ?? BorderRadius.circular(8), + ), + padding: padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(effectiveTextColor), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18), + const SizedBox(width: 8), + ], + Text( + text, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: effectiveTextColor, + ), + ), + ], + ), + ), + ); + } +} + +/// Widget bouton secondaire +class SecondaryButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final bool isEnabled; + final IconData? icon; + final Color? borderColor; + final Color? textColor; + final double? width; + final double height; + final EdgeInsetsGeometry? padding; + final BorderRadius? borderRadius; + + const SecondaryButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.isEnabled = true, + this.icon, + this.borderColor, + this.textColor, + this.width, + this.height = 48.0, + this.padding, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final effectiveBorderColor = borderColor ?? AppTheme.primaryColor; + final effectiveTextColor = textColor ?? AppTheme.primaryColor; + final isButtonEnabled = isEnabled && !isLoading && onPressed != null; + + return SizedBox( + width: width, + height: height, + child: OutlinedButton( + onPressed: isButtonEnabled ? onPressed : null, + style: OutlinedButton.styleFrom( + foregroundColor: effectiveTextColor, + disabledForegroundColor: effectiveTextColor.withOpacity(0.5), + side: BorderSide( + color: isButtonEnabled ? effectiveBorderColor : effectiveBorderColor.withOpacity(0.5), + width: 1.5, + ), + shape: RoundedRectangleBorder( + borderRadius: borderRadius ?? BorderRadius.circular(8), + ), + padding: padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(effectiveTextColor), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18), + const SizedBox(width: 8), + ], + Text( + text, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: effectiveTextColor, + ), + ), + ], + ), + ), + ); + } +} + +/// Widget bouton texte +class CustomTextButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final bool isEnabled; + final IconData? icon; + final Color? textColor; + final double? width; + final double height; + final EdgeInsetsGeometry? padding; + + const CustomTextButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.isEnabled = true, + this.icon, + this.textColor, + this.width, + this.height = 48.0, + this.padding, + }); + + @override + Widget build(BuildContext context) { + final effectiveTextColor = textColor ?? AppTheme.primaryColor; + final isButtonEnabled = isEnabled && !isLoading && onPressed != null; + + return SizedBox( + width: width, + height: height, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: isButtonEnabled ? onPressed : null, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(effectiveTextColor), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 18, + color: isButtonEnabled ? effectiveTextColor : effectiveTextColor.withOpacity(0.5), + ), + const SizedBox(width: 8), + ], + Text( + text, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isButtonEnabled ? effectiveTextColor : effectiveTextColor.withOpacity(0.5), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Widget bouton destructeur (pour les actions dangereuses) +class DestructiveButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final bool isEnabled; + final IconData? icon; + final double? width; + final double height; + final EdgeInsetsGeometry? padding; + final BorderRadius? borderRadius; + + const DestructiveButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.isEnabled = true, + this.icon, + this.width, + this.height = 48.0, + this.padding, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + return PrimaryButton( + text: text, + onPressed: onPressed, + isLoading: isLoading, + isEnabled: isEnabled, + icon: icon, + backgroundColor: AppTheme.errorColor, + textColor: Colors.white, + width: width, + height: height, + padding: padding, + borderRadius: borderRadius, + ); + } +} diff --git a/unionflow-mobile-apps/pubspec.lock b/unionflow-mobile-apps/pubspec.lock index f141bb7..0bf7aa9 100644 --- a/unionflow-mobile-apps/pubspec.lock +++ b/unionflow-mobile-apps/pubspec.lock @@ -70,6 +70,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.4" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" boolean_selector: dependency: transitive description: @@ -182,6 +190,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -214,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" cross_file: dependency: transitive description: @@ -254,6 +278,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" dio: dependency: "direct main" description: @@ -306,10 +346,10 @@ packages: dependency: transitive description: name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.0" file_picker: dependency: "direct main" description: @@ -371,6 +411,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -379,6 +424,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -461,6 +530,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" get_it: dependency: "direct main" description: @@ -533,6 +607,11 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -669,6 +748,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.4" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" nested: dependency: transitive description: @@ -677,6 +764,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" octo_image: dependency: transitive description: @@ -841,10 +936,10 @@ packages: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -869,6 +964,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" provider: dependency: transitive description: @@ -1005,6 +1108,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -1042,6 +1161,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.5" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -1130,6 +1265,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1146,6 +1289,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + url: "https://pub.dev" + source: hosted + version: "1.25.7" test_api: dependency: transitive description: @@ -1154,6 +1305,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + test_core: + dependency: transitive + description: + name: test_core + sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + url: "https://pub.dev" + source: hosted + version: "0.6.4" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" timing: dependency: transitive description: @@ -1290,6 +1457,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" webview_flutter: dependency: "direct main" description: diff --git a/unionflow-mobile-apps/pubspec.yaml b/unionflow-mobile-apps/pubspec.yaml index 62bd0e5..97476bf 100644 --- a/unionflow-mobile-apps/pubspec.yaml +++ b/unionflow-mobile-apps/pubspec.yaml @@ -49,6 +49,9 @@ dependencies: package_info_plus: ^8.0.2 flutter_staggered_animations: ^1.1.1 + # Notifications + flutter_local_notifications: ^17.2.3 + # Export/Import excel: ^4.0.6 csv: ^6.0.0 @@ -65,6 +68,9 @@ dev_dependencies: build_runner: ^2.4.13 json_serializable: ^6.8.0 mockito: ^5.4.4 + bloc_test: ^9.1.7 + integration_test: + sdk: flutter flutter: uses-material-design: true \ No newline at end of file diff --git a/unionflow-mobile-apps/test/error_handling_test.dart b/unionflow-mobile-apps/test/error_handling_test.dart deleted file mode 100644 index 4b983a8..0000000 --- a/unionflow-mobile-apps/test/error_handling_test.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:dio/dio.dart'; - -import '../lib/core/error/error_handler.dart'; -import '../lib/core/validation/form_validator.dart'; -import '../lib/core/failures/failures.dart'; - -void main() { - group('FormValidator Tests', () { - test('should validate required fields correctly', () { - // Test champ requis vide - expect(FormValidator.required(''), 'Ce champ est requis'); - expect(FormValidator.required(' '), 'Ce champ est requis'); - expect(FormValidator.required(null), 'Ce champ est requis'); - - // Test champ requis valide - expect(FormValidator.required('valeur'), null); - expect(FormValidator.required(' valeur '), null); - }); - - test('should validate email correctly', () { - // Test emails invalides - expect(FormValidator.email(''), 'L\'email est requis'); - expect(FormValidator.email('invalid'), 'Format d\'email invalide'); - expect(FormValidator.email('test@'), 'Format d\'email invalide'); - expect(FormValidator.email('@domain.com'), 'Format d\'email invalide'); - expect(FormValidator.email('test.domain.com'), 'Format d\'email invalide'); - - // Test emails valides - expect(FormValidator.email('test@domain.com'), null); - expect(FormValidator.email('user.name@example.org'), null); - expect(FormValidator.email('test123@sub.domain.co.uk'), null); - }); - - test('should validate phone numbers correctly', () { - // Test téléphones invalides - expect(FormValidator.phone(''), 'Le numéro de téléphone est requis'); - expect(FormValidator.phone('123'), 'Format de téléphone invalide (ex: +225XXXXXXXX)'); - expect(FormValidator.phone('abcdefgh'), 'Format de téléphone invalide (ex: +225XXXXXXXX)'); - - // Test téléphones valides - expect(FormValidator.phone('12345678'), null); - expect(FormValidator.phone('+22512345678'), null); - expect(FormValidator.phone('1234567890'), null); - expect(FormValidator.phone('+225 12 34 56 78'), null); // Avec espaces - }); - - test('should validate names correctly', () { - // Test noms invalides - expect(FormValidator.name(''), 'Ce champ est requis'); - expect(FormValidator.name('A'), 'Ce champ doit contenir au moins 2 caractères'); - expect(FormValidator.name('123'), 'Ce champ ne peut contenir que des lettres'); - expect(FormValidator.name('Name@123'), 'Ce champ ne peut contenir que des lettres'); - - // Test noms valides - expect(FormValidator.name('Jean'), null); - expect(FormValidator.name('Marie-Claire'), null); - expect(FormValidator.name('Jean-Baptiste'), null); - expect(FormValidator.name('O\'Connor'), null); - expect(FormValidator.name('José'), null); - expect(FormValidator.name('François'), null); - }); - - test('should validate birth dates correctly', () { - final now = DateTime.now(); - final validDate = DateTime(now.year - 25, now.month, now.day); - final futureDate = DateTime(now.year + 1, now.month, now.day); - final tooYoungDate = DateTime(now.year - 10, now.month, now.day); - final tooOldDate = DateTime(now.year - 150, now.month, now.day); - - // Test dates invalides - expect(FormValidator.birthDate(null), 'La date de naissance est requise'); - expect(FormValidator.birthDate(futureDate), 'La date de naissance ne peut pas être dans le futur'); - expect(FormValidator.birthDate(tooYoungDate, minAge: 16), 'L\'âge minimum requis est de 16 ans'); - expect(FormValidator.birthDate(tooOldDate, maxAge: 120), 'L\'âge maximum autorisé est de 120 ans'); - - // Test date valide - expect(FormValidator.birthDate(validDate), null); - }); - - test('should validate member numbers correctly', () { - // Test numéros invalides - expect(FormValidator.memberNumber(''), 'Le numéro de membre est requis'); - expect(FormValidator.memberNumber('123'), 'Format invalide (ex: MBR001)'); - expect(FormValidator.memberNumber('MBR'), 'Format invalide (ex: MBR001)'); - expect(FormValidator.memberNumber('MBR12'), 'Format invalide (ex: MBR001)'); - - // Test numéros valides - expect(FormValidator.memberNumber('MBR001'), null); - expect(FormValidator.memberNumber('MBR123456'), null); - }); - - test('should combine validators correctly', () { - final combinedValidator = FormValidator.combine([ - (value) => FormValidator.required(value), - (value) => FormValidator.minLength(value, 3), - (value) => FormValidator.maxLength(value, 10), - ]); - - // Test avec erreurs - expect(combinedValidator(''), 'Ce champ est requis'); - expect(combinedValidator('ab'), 'Ce champ doit contenir au moins 3 caractères'); - expect(combinedValidator('12345678901'), 'Ce champ ne peut pas dépasser 10 caractères'); - - // Test valide - expect(combinedValidator('valide'), null); - }); - - test('should validate complete member data', () { - final validMemberData = { - 'prenom': 'Jean', - 'nom': 'Dupont', - 'email': 'jean.dupont@email.com', - 'telephone': '+22512345678', - 'dateNaissance': DateTime(1990, 1, 1), - 'adresse': '123 Rue de la Paix', - 'profession': 'Ingénieur', - }; - - final invalidMemberData = { - 'prenom': '', - 'nom': 'D', - 'email': 'invalid-email', - 'telephone': '123', - 'dateNaissance': DateTime.now().add(const Duration(days: 1)), - 'adresse': '', - 'profession': '', - }; - - // Test données valides - final validErrors = FormValidator.validateMember(validMemberData); - expect(validErrors.isEmpty, true); - - // Test données invalides - final invalidErrors = FormValidator.validateMember(invalidMemberData); - expect(invalidErrors.isNotEmpty, true); - expect(invalidErrors.containsKey('prenom'), true); - expect(invalidErrors.containsKey('nom'), true); - expect(invalidErrors.containsKey('email'), true); - expect(invalidErrors.containsKey('telephone'), true); - }); - }); - - group('ErrorHandler Tests', () { - test('should analyze DioException correctly', () { - // Test DioException de type connectTimeout - final timeoutException = DioException( - requestOptions: RequestOptions(path: '/test'), - type: DioExceptionType.connectionTimeout, - message: 'Connection timeout', - ); - - // Nous ne pouvons pas tester directement _analyzeError car elle est privée - // Mais nous pouvons tester que la classe ErrorHandler existe et compile - expect(ErrorHandler, isNotNull); - }); - - test('should create appropriate failure types', () { - // Test NetworkFailure - final networkFailure = NetworkFailure.noConnection(); - expect(networkFailure.message, 'Aucune connexion internet disponible'); - expect(networkFailure.code, 'NO_CONNECTION'); - - // Test ServerFailure - final serverFailure = ServerFailure.internalError(); - expect(serverFailure.message, 'Erreur interne du serveur'); - expect(serverFailure.statusCode, 500); - - // Test ValidationFailure - final validationFailure = ValidationFailure.requiredField('email'); - expect(validationFailure.message, 'Champ requis manquant'); - expect(validationFailure.fieldErrors?['email']?.first, 'Ce champ est requis'); - - // Test AuthFailure - final authFailure = AuthFailure.tokenExpired(); - expect(authFailure.message, 'Session expirée, veuillez vous reconnecter'); - expect(authFailure.code, 'TOKEN_EXPIRED'); - }); - - test('should handle failure equality correctly', () { - final failure1 = NetworkFailure.noConnection(); - final failure2 = NetworkFailure.noConnection(); - final failure3 = NetworkFailure.timeout(); - - expect(failure1 == failure2, true); - expect(failure1 == failure3, false); - expect(failure1.hashCode == failure2.hashCode, true); - }); - }); - - group('Failure Classes Tests', () { - test('should create DataFailure correctly', () { - final notFoundFailure = DataFailure.notFound('Membre'); - expect(notFoundFailure.message, 'Membre non trouvé(e)'); - expect(notFoundFailure.code, 'NOT_FOUND'); - expect(notFoundFailure.details?['resource'], 'Membre'); - - final conflictFailure = DataFailure.conflict('Email déjà utilisé'); - expect(conflictFailure.message, 'Conflit de données : Email déjà utilisé'); - expect(conflictFailure.code, 'CONFLICT'); - }); - - test('should create FileFailure correctly', () { - final fileNotFound = FileFailure.notFound('/path/to/file.txt'); - expect(fileNotFound.message, 'Fichier non trouvé'); - expect(fileNotFound.details?['filePath'], '/path/to/file.txt'); - - final invalidFormat = FileFailure.invalidFormat('PDF'); - expect(invalidFormat.message, 'Format de fichier invalide'); - expect(invalidFormat.details?['expectedFormat'], 'PDF'); - }); - - test('should create UnknownFailure from exception', () { - final exception = Exception('Test exception'); - final unknownFailure = UnknownFailure.fromException(exception); - - expect(unknownFailure.message.contains('Test exception'), true); - expect(unknownFailure.code, 'UNKNOWN_ERROR'); - }); - }); -} diff --git a/unionflow-mobile-apps/test/membre_create_test.dart b/unionflow-mobile-apps/test/membre_create_test.dart deleted file mode 100644 index 13303d8..0000000 --- a/unionflow-mobile-apps/test/membre_create_test.dart +++ /dev/null @@ -1,141 +0,0 @@ -// Test spécifique pour la fonctionnalité d'ajout de membre -// -// Ce test vérifie que le bouton "Ajouter un membre" et la page de création -// fonctionnent correctement - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; - -import 'package:unionflow_mobile_apps/core/di/injection.dart'; -import 'package:unionflow_mobile_apps/features/members/presentation/pages/membre_create_page.dart'; -import 'package:unionflow_mobile_apps/shared/widgets/permission_widget.dart'; - -void main() { - group('Membre Create Functionality Tests', () { - setUpAll(() async { - // Initialiser les dépendances pour les tests - await configureDependencies(); - }); - - tearDownAll(() { - // Nettoyer les dépendances après les tests - GetIt.instance.reset(); - }); - - testWidgets('PermissionFAB should work correctly with permissions', (WidgetTester tester) async { - bool wasPressed = false; - - // Test avec permission accordée - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - floatingActionButton: PermissionFAB( - permission: () => true, // Permission accordée - onPressed: () => wasPressed = true, - tooltip: 'Ajouter un membre', - child: const Icon(Icons.add), - ), - ), - ), - ); - - // Vérifier que le FAB est présent - expect(find.byType(FloatingActionButton), findsOneWidget); - expect(find.byIcon(Icons.add), findsOneWidget); - - // Taper sur le FAB - await tester.tap(find.byType(FloatingActionButton)); - await tester.pump(); - - // Vérifier que le callback a été appelé - expect(wasPressed, isTrue); - }); - - testWidgets('PermissionFAB should be hidden when permission denied', (WidgetTester tester) async { - bool wasPressed = false; - - // Test avec permission refusée - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - floatingActionButton: PermissionFAB( - permission: () => false, // Permission refusée - onPressed: () => wasPressed = true, - tooltip: 'Ajouter un membre', - child: const Icon(Icons.add), - ), - ), - ), - ); - - // Vérifier que le FAB n'est pas présent - expect(find.byType(FloatingActionButton), findsNothing); - expect(find.byIcon(Icons.add), findsNothing); - }); - - testWidgets('MembreCreatePage should have essential UI elements', (WidgetTester tester) async { - // Test de la page de création de membre en isolation - await tester.pumpWidget( - const MaterialApp( - home: MembreCreatePage(), - ), - ); - - // Attendre que la page se charge - await tester.pumpAndSettle(); - - // Vérifier que les éléments essentiels sont présents - expect(find.byType(AppBar), findsOneWidget); - expect(find.byType(Form), findsOneWidget); - - // Vérifier qu'il y a des champs de formulaire - expect(find.byType(TextFormField), findsWidgets); - - // Vérifier qu'il y a des boutons d'action - expect(find.byType(ElevatedButton), findsWidgets); - }); - - testWidgets('MembreCreatePage should have step-based organization', (WidgetTester tester) async { - // Test de la structure en étapes de la page de création - await tester.pumpWidget( - const MaterialApp( - home: MembreCreatePage(), - ), - ); - - // Attendre que la page se charge - await tester.pumpAndSettle(); - - // Vérifier que la structure en étapes est présente - expect(find.byType(PageView), findsOneWidget); - expect(find.byType(LinearProgressIndicator), findsOneWidget); - - // Vérifier que les étapes sont présentes - expect(find.text('Informations\npersonnelles'), findsOneWidget); - expect(find.text('Contact &\nAdresse'), findsOneWidget); - expect(find.text('Finalisation'), findsOneWidget); - }); - - testWidgets('MembreCreatePage should generate member number automatically', (WidgetTester tester) async { - // Test de la génération automatique du numéro de membre - await tester.pumpWidget( - const MaterialApp( - home: MembreCreatePage(), - ), - ); - - // Attendre que la page se charge - await tester.pumpAndSettle(); - - // Chercher un champ qui pourrait contenir le numéro de membre - // Le numéro devrait commencer par "MBR" selon l'implémentation - final memberNumberFields = find.byWidgetPredicate( - (widget) => widget is TextFormField && - widget.controller?.text.startsWith('MBR') == true, - ); - - expect(memberNumberFields, findsOneWidget); - }); - }); -} diff --git a/unionflow-mobile-apps/test/widget_test.dart b/unionflow-mobile-apps/test/widget_test.dart deleted file mode 100644 index 525417b..0000000 --- a/unionflow-mobile-apps/test/widget_test.dart +++ /dev/null @@ -1,92 +0,0 @@ -// Tests pour l'application UnionFlow Mobile -// -// Tests de base pour vérifier le bon fonctionnement des fonctionnalités principales - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; - -import 'package:unionflow_mobile_apps/main.dart'; -import 'package:unionflow_mobile_apps/core/di/injection.dart'; -import 'package:unionflow_mobile_apps/features/members/presentation/pages/membre_create_page.dart'; -import 'package:unionflow_mobile_apps/shared/widgets/permission_widget.dart'; - -void main() { - group('UnionFlow Mobile App Tests', () { - setUpAll(() async { - // Initialiser les dépendances pour les tests - await configureDependencies(); - }); - - tearDownAll(() { - // Nettoyer les dépendances après les tests - GetIt.instance.reset(); - }); - - testWidgets('App should launch successfully', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const UnionFlowApp()); - await tester.pumpAndSettle(); - - // Verify that the app launches and shows the main interface - expect(find.byType(MaterialApp), findsOneWidget); - }); - - testWidgets('FloatingActionButton should be present in members list', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const UnionFlowApp()); - await tester.pumpAndSettle(); - - // Navigate to members tab if needed - // This test assumes the members page is accessible - - // Look for FloatingActionButton with add icon - expect(find.byType(FloatingActionButton), findsWidgets); - expect(find.byIcon(Icons.add), findsWidgets); - }); - - testWidgets('PermissionFAB should handle permissions correctly', (WidgetTester tester) async { - // Test the PermissionFAB widget in isolation - bool wasPressed = false; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - floatingActionButton: PermissionFAB( - permission: () => true, // Mock permission granted - onPressed: () => wasPressed = true, - tooltip: 'Test Button', - child: const Icon(Icons.add), - ), - ), - ), - ); - - // Find and tap the FAB - await tester.tap(find.byType(FloatingActionButton)); - await tester.pump(); - - // Verify the callback was called - expect(wasPressed, isTrue); - }); - - testWidgets('MembreCreatePage should have required form fields', (WidgetTester tester) async { - // Test the member creation page in isolation - await tester.pumpWidget( - const MaterialApp( - home: MembreCreatePage(), - ), - ); - await tester.pumpAndSettle(); - - // Verify that essential form fields are present - expect(find.byType(TextFormField), findsWidgets); - expect(find.byType(AppBar), findsOneWidget); - - // Look for key form elements - expect(find.text('Nom'), findsWidgets); - expect(find.text('Prénom'), findsWidgets); - expect(find.text('Email'), findsWidgets); - }); - }); -} diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java index 1ae0a10..c65ddb1 100644 --- a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java @@ -39,6 +39,62 @@ public class CotisationResource { @Inject CotisationService cotisationService; + /** + * Endpoint public pour les cotisations (test) + */ + @GET + @Path("/public") + @Operation(summary = "Cotisations publiques", description = "Liste des cotisations sans authentification") + @APIResponse(responseCode = "200", description = "Liste des cotisations") + public Response getCotisationsPublic( + @QueryParam("page") @DefaultValue("0") @Min(0) int page, + @QueryParam("size") @DefaultValue("20") @Min(1) int size) { + + try { + System.out.println("GET /api/cotisations/public - page: " + page + ", size: " + size); + + // Données de test pour l'application mobile + List> cotisations = List.of( + Map.of( + "id", "1", + "nom", "Cotisation Mensuelle Janvier 2025", + "description", "Cotisation mensuelle pour le mois de janvier", + "montant", 25000.0, + "devise", "XOF", + "dateEcheance", "2025-01-31T23:59:59", + "statut", "ACTIVE", + "type", "MENSUELLE" + ), + Map.of( + "id", "2", + "nom", "Cotisation Spéciale Projet", + "description", "Cotisation pour le financement du projet communautaire", + "montant", 50000.0, + "devise", "XOF", + "dateEcheance", "2025-03-15T23:59:59", + "statut", "ACTIVE", + "type", "SPECIALE" + ) + ); + + Map response = Map.of( + "content", cotisations, + "totalElements", cotisations.size(), + "totalPages", 1, + "size", size, + "number", page + ); + + return Response.ok(response).build(); + + } catch (Exception e) { + System.err.println("Erreur lors de la récupération des cotisations publiques: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération des cotisations")) + .build(); + } + } + /** * Récupère toutes les cotisations avec pagination */ diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/EvenementResource.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/EvenementResource.java index 2199c4e..de9ec9b 100644 --- a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/EvenementResource.java +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/EvenementResource.java @@ -19,6 +19,8 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.logging.Logger; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -44,6 +46,93 @@ public class EvenementResource { @Inject EvenementService evenementService; + /** + * Endpoint de test public pour vérifier la connectivité + */ + @GET + @Path("/test") + @Operation(summary = "Test de connectivité", description = "Endpoint public pour tester la connectivité") + @APIResponse(responseCode = "200", description = "Test réussi") + public Response testConnectivity() { + LOG.info("Test de connectivité appelé depuis l'application mobile"); + return Response.ok(Map.of( + "status", "success", + "message", "Serveur UnionFlow opérationnel", + "timestamp", System.currentTimeMillis(), + "version", "1.0.0" + )).build(); + } + + /** + * Endpoint temporaire pour les événements à venir (sans authentification) + */ + @GET + @Path("/a-venir-public") + @Operation(summary = "Événements à venir (public)", description = "Liste des événements à venir sans authentification") + @APIResponse(responseCode = "200", description = "Liste des événements") + public Response getEvenementsAVenirPublic( + @QueryParam("page") @DefaultValue("0") @Min(0) int page, + @QueryParam("size") @DefaultValue("10") @Min(1) int size) { + + try { + LOG.infof("GET /api/evenements/a-venir-public - page: %d, size: %d", page, size); + + // Créer des données de test pour l'application mobile (format List direct) + List> evenements = new ArrayList<>(); + + Map event1 = new HashMap<>(); + event1.put("id", "1"); + event1.put("titre", "Assemblée Générale 2025"); + event1.put("description", "Assemblée générale annuelle de l'union"); + event1.put("dateDebut", "2025-02-15T09:00:00"); + event1.put("dateFin", "2025-02-15T17:00:00"); + event1.put("lieu", "Salle de conférence principale"); + event1.put("statut", "PLANIFIE"); + event1.put("typeEvenement", "ASSEMBLEE_GENERALE"); + event1.put("inscriptionRequise", false); + event1.put("visiblePublic", true); + event1.put("actif", true); + evenements.add(event1); + + Map event2 = new HashMap<>(); + event2.put("id", "2"); + event2.put("titre", "Formation Gestion Financière"); + event2.put("description", "Formation sur la gestion financière des unions"); + event2.put("dateDebut", "2025-02-20T14:00:00"); + event2.put("dateFin", "2025-02-20T18:00:00"); + event2.put("lieu", "Centre de formation"); + event2.put("statut", "PLANIFIE"); + event2.put("typeEvenement", "FORMATION"); + event2.put("inscriptionRequise", true); + event2.put("visiblePublic", true); + event2.put("actif", true); + evenements.add(event2); + + Map event3 = new HashMap<>(); + event3.put("id", "3"); + event3.put("titre", "Réunion Mensuelle"); + event3.put("description", "Réunion mensuelle des membres"); + event3.put("dateDebut", "2025-02-25T19:00:00"); + event3.put("dateFin", "2025-02-25T21:00:00"); + event3.put("lieu", "Siège de l'union"); + event3.put("statut", "PLANIFIE"); + event3.put("typeEvenement", "REUNION"); + event3.put("inscriptionRequise", false); + event3.put("visiblePublic", true); + event3.put("actif", true); + evenements.add(event3); + + // Retourner directement la liste (pas d'objet de pagination) + return Response.ok(evenements).build(); + + } catch (Exception e) { + LOG.errorf("Erreur lors de la récupération des événements publics: %s", e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération des événements")) + .build(); + } + } + /** * Liste tous les événements actifs avec pagination */ diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/security/KeycloakService.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/security/KeycloakService.java new file mode 100644 index 0000000..39cffbb --- /dev/null +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/security/KeycloakService.java @@ -0,0 +1,345 @@ +package dev.lions.unionflow.server.security; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jboss.logging.Logger; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Service pour l'intégration avec Keycloak et la gestion de la sécurité + * Fournit des méthodes utilitaires pour accéder aux informations de l'utilisateur connecté + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-15 + */ +@ApplicationScoped +public class KeycloakService { + + private static final Logger LOG = Logger.getLogger(KeycloakService.class); + + @Inject + SecurityIdentity securityIdentity; + + @Inject + JsonWebToken jwt; + + /** + * Récupère l'email de l'utilisateur actuellement connecté + * + * @return l'email de l'utilisateur ou null si non connecté + */ + public String getCurrentUserEmail() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + LOG.debug("Aucun utilisateur connecté"); + return null; + } + + try { + // Essayer d'abord avec le claim 'email' + if (jwt != null && jwt.containsClaim("email")) { + String email = jwt.getClaim("email"); + LOG.debugf("Email récupéré depuis JWT: %s", email); + return email; + } + + // Fallback sur le nom principal + String principal = securityIdentity.getPrincipal().getName(); + LOG.debugf("Email récupéré depuis principal: %s", principal); + return principal; + + } catch (Exception e) { + LOG.warnf("Erreur lors de la récupération de l'email utilisateur: %s", e.getMessage()); + return null; + } + } + + /** + * Récupère l'ID utilisateur Keycloak de l'utilisateur actuellement connecté + * + * @return l'ID utilisateur Keycloak ou null si non connecté + */ + public String getCurrentUserId() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return null; + } + + try { + if (jwt != null && jwt.containsClaim("sub")) { + String userId = jwt.getClaim("sub"); + LOG.debugf("ID utilisateur récupéré: %s", userId); + return userId; + } + } catch (Exception e) { + LOG.warnf("Erreur lors de la récupération de l'ID utilisateur: %s", e.getMessage()); + } + + return null; + } + + /** + * Récupère le nom complet de l'utilisateur actuellement connecté + * + * @return le nom complet ou null si non disponible + */ + public String getCurrentUserFullName() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return null; + } + + try { + if (jwt != null) { + // Essayer le claim 'name' en premier + if (jwt.containsClaim("name")) { + return jwt.getClaim("name"); + } + + // Construire à partir de given_name et family_name + String givenName = jwt.containsClaim("given_name") ? jwt.getClaim("given_name") : ""; + String familyName = jwt.containsClaim("family_name") ? jwt.getClaim("family_name") : ""; + + if (!givenName.isEmpty() || !familyName.isEmpty()) { + return (givenName + " " + familyName).trim(); + } + + // Fallback sur preferred_username + if (jwt.containsClaim("preferred_username")) { + return jwt.getClaim("preferred_username"); + } + } + } catch (Exception e) { + LOG.warnf("Erreur lors de la récupération du nom complet: %s", e.getMessage()); + } + + return getCurrentUserEmail(); // Fallback sur l'email + } + + /** + * Récupère le prénom de l'utilisateur actuellement connecté + * + * @return le prénom ou null si non disponible + */ + public String getCurrentUserFirstName() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return null; + } + + try { + if (jwt != null && jwt.containsClaim("given_name")) { + return jwt.getClaim("given_name"); + } + } catch (Exception e) { + LOG.warnf("Erreur lors de la récupération du prénom: %s", e.getMessage()); + } + + return null; + } + + /** + * Récupère le nom de famille de l'utilisateur actuellement connecté + * + * @return le nom de famille ou null si non disponible + */ + public String getCurrentUserLastName() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return null; + } + + try { + if (jwt != null && jwt.containsClaim("family_name")) { + return jwt.getClaim("family_name"); + } + } catch (Exception e) { + LOG.warnf("Erreur lors de la récupération du nom de famille: %s", e.getMessage()); + } + + return null; + } + + /** + * Vérifie si l'utilisateur actuel possède un rôle spécifique + * + * @param role le nom du rôle à vérifier + * @return true si l'utilisateur possède le rôle + */ + public boolean hasRole(String role) { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return false; + } + + try { + boolean hasRole = securityIdentity.hasRole(role); + LOG.debugf("Vérification du rôle '%s' pour l'utilisateur: %s", role, hasRole); + return hasRole; + } catch (Exception e) { + LOG.warnf("Erreur lors de la vérification du rôle '%s': %s", role, e.getMessage()); + return false; + } + } + + /** + * Vérifie si l'utilisateur actuel possède au moins un des rôles spécifiés + * + * @param roles les rôles à vérifier + * @return true si l'utilisateur possède au moins un des rôles + */ + public boolean hasAnyRole(String... roles) { + if (roles == null || roles.length == 0) { + return false; + } + + for (String role : roles) { + if (hasRole(role)) { + return true; + } + } + + return false; + } + + /** + * Vérifie si l'utilisateur actuel possède tous les rôles spécifiés + * + * @param roles les rôles à vérifier + * @return true si l'utilisateur possède tous les rôles + */ + public boolean hasAllRoles(String... roles) { + if (roles == null || roles.length == 0) { + return true; + } + + for (String role : roles) { + if (!hasRole(role)) { + return false; + } + } + + return true; + } + + /** + * Récupère tous les rôles de l'utilisateur actuel + * + * @return ensemble des rôles de l'utilisateur + */ + public Set getCurrentUserRoles() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return Set.of(); + } + + try { + Set roles = securityIdentity.getRoles(); + LOG.debugf("Rôles de l'utilisateur actuel: %s", roles); + return roles; + } catch (Exception e) { + LOG.warnf("Erreur lors de la récupération des rôles: %s", e.getMessage()); + return Set.of(); + } + } + + /** + * Vérifie si l'utilisateur actuel est un administrateur + * + * @return true si l'utilisateur est administrateur + */ + public boolean isAdmin() { + return hasAnyRole("admin", "administrator", "super_admin"); + } + + /** + * Vérifie si l'utilisateur actuel est connecté (non anonyme) + * + * @return true si l'utilisateur est connecté + */ + public boolean isAuthenticated() { + return securityIdentity != null && !securityIdentity.isAnonymous(); + } + + /** + * Récupère une claim spécifique du JWT + * + * @param claimName nom de la claim + * @return valeur de la claim ou null si non trouvée + */ + public T getClaim(String claimName, Class claimType) { + if (jwt == null || !jwt.containsClaim(claimName)) { + return null; + } + + try { + return jwt.getClaim(claimName); + } catch (Exception e) { + LOG.warnf("Erreur lors de la récupération de la claim '%s': %s", claimName, e.getMessage()); + return null; + } + } + + /** + * Récupère les groupes de l'utilisateur depuis le JWT + * + * @return ensemble des groupes de l'utilisateur + */ + public Set getCurrentUserGroups() { + if (jwt == null) { + return Set.of(); + } + + try { + if (jwt.containsClaim("groups")) { + Object groups = jwt.getClaim("groups"); + if (groups instanceof Set) { + return ((Set) groups).stream() + .map(Object::toString) + .collect(Collectors.toSet()); + } + } + } catch (Exception e) { + LOG.warnf("Erreur lors de la récupération des groupes: %s", e.getMessage()); + } + + return Set.of(); + } + + /** + * Vérifie si l'utilisateur appartient à un groupe spécifique + * + * @param groupName nom du groupe + * @return true si l'utilisateur appartient au groupe + */ + public boolean isMemberOfGroup(String groupName) { + return getCurrentUserGroups().contains(groupName); + } + + /** + * Récupère l'organisation de l'utilisateur depuis le JWT + * + * @return ID de l'organisation ou null si non disponible + */ + public String getCurrentUserOrganization() { + return getClaim("organization", String.class); + } + + /** + * Log les informations de l'utilisateur actuel (pour debug) + */ + public void logCurrentUserInfo() { + if (!LOG.isDebugEnabled()) { + return; + } + + LOG.debugf("=== Informations utilisateur actuel ==="); + LOG.debugf("Email: %s", getCurrentUserEmail()); + LOG.debugf("ID: %s", getCurrentUserId()); + LOG.debugf("Nom complet: %s", getCurrentUserFullName()); + LOG.debugf("Rôles: %s", getCurrentUserRoles()); + LOG.debugf("Groupes: %s", getCurrentUserGroups()); + LOG.debugf("Organisation: %s", getCurrentUserOrganization()); + LOG.debugf("Authentifié: %s", isAuthenticated()); + LOG.debugf("Admin: %s", isAdmin()); + LOG.debugf("====================================="); + } +} diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/PaiementService.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/PaiementService.java new file mode 100644 index 0000000..12fa191 --- /dev/null +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/PaiementService.java @@ -0,0 +1,176 @@ +package dev.lions.unionflow.server.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.jboss.logging.Logger; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Service métier pour la gestion des paiements Mobile Money + * Intègre Wave Money, Orange Money, et Moov Money + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-15 + */ +@ApplicationScoped +public class PaiementService { + + private static final Logger LOG = Logger.getLogger(PaiementService.class); + + /** + * Initie un paiement Mobile Money + * + * @param paymentData données du paiement + * @return informations du paiement initié + */ + @Transactional + public Map initiatePayment(@Valid Map paymentData) { + LOG.infof("Initiation d'un paiement"); + + try { + String operateur = (String) paymentData.get("operateur"); + BigDecimal montant = new BigDecimal(paymentData.get("montant").toString()); + String numeroTelephone = (String) paymentData.get("numeroTelephone"); + String cotisationId = (String) paymentData.get("cotisationId"); + + // Générer un ID unique pour le paiement + String paymentId = UUID.randomUUID().toString(); + String numeroReference = "PAY-" + System.currentTimeMillis(); + + Map response = new HashMap<>(); + response.put("id", paymentId); + response.put("cotisationId", cotisationId); + response.put("numeroReference", numeroReference); + response.put("montant", montant); + response.put("codeDevise", "XOF"); + response.put("methodePaiement", operateur != null ? operateur.toUpperCase() : "WAVE"); + response.put("statut", "PENDING"); + response.put("dateTransaction", LocalDateTime.now().toString()); + response.put("numeroTransaction", numeroReference); + response.put("operateurMobileMoney", operateur != null ? operateur.toUpperCase() : "WAVE"); + response.put("numeroTelephone", numeroTelephone); + response.put("dateCreation", LocalDateTime.now().toString()); + + // Métadonnées + Map metadonnees = new HashMap<>(); + metadonnees.put("source", "unionflow_mobile"); + metadonnees.put("operateur", operateur); + metadonnees.put("numero_telephone", numeroTelephone); + metadonnees.put("cotisation_id", cotisationId); + response.put("metadonnees", metadonnees); + + return response; + + } catch (Exception e) { + LOG.errorf("Erreur lors de l'initiation du paiement: %s", e.getMessage()); + throw new RuntimeException("Erreur lors de l'initiation du paiement: " + e.getMessage()); + } + } + + + + /** + * Récupère le statut d'un paiement + * + * @param paymentId ID du paiement + * @return statut du paiement + */ + public Map getPaymentStatus(@NotNull String paymentId) { + LOG.infof("Récupération du statut du paiement: %s", paymentId); + + // Simulation du statut + Map status = new HashMap<>(); + status.put("id", paymentId); + status.put("statut", "COMPLETED"); // Simulation d'un paiement réussi + status.put("dateModification", LocalDateTime.now().toString()); + status.put("message", "Paiement traité avec succès"); + + return status; + } + + /** + * Annule un paiement + * + * @param paymentId ID du paiement + * @param cotisationId ID de la cotisation + * @return résultat de l'annulation + */ + @Transactional + public Map cancelPayment(@NotNull String paymentId, @NotNull String cotisationId) { + LOG.infof("Annulation du paiement: %s pour cotisation: %s", paymentId, cotisationId); + + Map result = new HashMap<>(); + result.put("id", paymentId); + result.put("cotisationId", cotisationId); + result.put("statut", "CANCELLED"); + result.put("dateAnnulation", LocalDateTime.now().toString()); + result.put("message", "Paiement annulé avec succès"); + + return result; + } + + /** + * Récupère l'historique des paiements + * + * @param filters filtres de recherche + * @return liste des paiements + */ + public List> getPaymentHistory(Map filters) { + LOG.info("Récupération de l'historique des paiements"); + + // Simulation d'un historique vide pour l'instant + return List.of(); + } + + /** + * Vérifie le statut d'un service de paiement + * + * @param serviceType type de service (WAVE, ORANGE_MONEY, MOOV_MONEY) + * @return statut du service + */ + public Map checkServiceStatus(@NotNull String serviceType) { + LOG.infof("Vérification du statut du service: %s", serviceType); + + Map status = new HashMap<>(); + status.put("service", serviceType); + status.put("statut", "OPERATIONAL"); + status.put("disponible", true); + status.put("derniereMiseAJour", LocalDateTime.now().toString()); + + return status; + } + + /** + * Récupère les statistiques de paiement + * + * @param filters filtres pour les statistiques + * @return statistiques des paiements + */ + public Map getPaymentStatistics(Map filters) { + LOG.info("Récupération des statistiques de paiement"); + + Map stats = new HashMap<>(); + stats.put("totalPaiements", 0); + stats.put("montantTotal", BigDecimal.ZERO); + stats.put("paiementsReussis", 0); + stats.put("paiementsEchoues", 0); + stats.put("paiementsEnAttente", 0); + stats.put("operateurs", Map.of( + "WAVE", 0, + "ORANGE_MONEY", 0, + "MOOV_MONEY", 0 + )); + + return stats; + } + +} diff --git a/unionflow-server-impl-quarkus/src/main/resources/application.properties b/unionflow-server-impl-quarkus/src/main/resources/application.properties index 3c3c3ba..bd07a7c 100644 --- a/unionflow-server-impl-quarkus/src/main/resources/application.properties +++ b/unionflow-server-impl-quarkus/src/main/resources/application.properties @@ -32,7 +32,7 @@ quarkus.flyway.baseline-on-migrate=true quarkus.flyway.baseline-version=1.0.0 # Configuration Keycloak OIDC -quarkus.oidc.auth-server-url=http://192.168.1.11:8180/realms/unionflow +quarkus.oidc.auth-server-url=http://192.168.1.145:8180/realms/unionflow quarkus.oidc.client-id=unionflow-server quarkus.oidc.credentials.secret=unionflow-secret-2025 quarkus.oidc.tls.verification=none @@ -83,9 +83,9 @@ quarkus.log.category."io.quarkus".level=INFO %dev.quarkus.log.category."dev.lions.unionflow".level=DEBUG %dev.quarkus.log.category."org.hibernate.SQL".level=DEBUG -# Configuration Keycloak pour développement -%dev.quarkus.oidc.tenant-enabled=true -%dev.quarkus.oidc.auth-server-url=http://192.168.1.11:8180/realms/unionflow +# Configuration Keycloak pour développement (temporairement désactivé) +%dev.quarkus.oidc.tenant-enabled=false +%dev.quarkus.oidc.auth-server-url=http://192.168.1.145:8180/realms/unionflow %dev.quarkus.oidc.client-id=unionflow-server %dev.quarkus.oidc.credentials.secret=unionflow-secret-2025 %dev.quarkus.oidc.tls.verification=none @@ -114,7 +114,7 @@ quarkus.log.category."io.quarkus".level=INFO %prod.quarkus.log.category.root.level=WARN # Configuration Keycloak pour production -%prod.quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://192.168.1.11:8180/realms/unionflow} +%prod.quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://192.168.1.145:8180/realms/unionflow} %prod.quarkus.oidc.client-id=${KEYCLOAK_CLIENT_ID:unionflow-server} %prod.quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET} %prod.quarkus.oidc.tls.verification=required