diff --git a/unionflow-mobile-apps/README.md b/unionflow-mobile-apps/README.md index 8ae5708..c1ffbc0 100644 --- a/unionflow-mobile-apps/README.md +++ b/unionflow-mobile-apps/README.md @@ -2,8 +2,8 @@ > Application mobile moderne pour la gestion d'associations en Côte d'Ivoire avec intégration Wave Money -[![React Native](https://img.shields.io/badge/React%20Native-0.73.2-blue.svg)](https://reactnative.dev/) -[![TypeScript](https://img.shields.io/badge/TypeScript-4.8.4-blue.svg)](https://www.typescriptlang.org/) +[![Flutter](https://img.shields.io/badge/Flutter-3.5.3-blue.svg)](https://flutter.dev/) +[![Dart](https://img.shields.io/badge/Dart-3.5.3-blue.svg)](https://dart.dev/) [![Wave Money](https://img.shields.io/badge/Wave%20Money-Intégré-orange.svg)](https://wave.com/) [![Côte d'Ivoire](https://img.shields.io/badge/Côte%20d'Ivoire-🇨🇮-green.svg)](https://www.gouv.ci/) @@ -26,7 +26,7 @@ ### 🎨 **Interface Ultra Moderne** - **Design System** cohérent inspiré des couleurs ivoiriennes -- **Animations fluides** avec Reanimated 3 +- **Animations fluides** avec Flutter Animations - **Mode sombre** automatique - **Responsive design** pour tous les écrans - **Accessibilité** complète (WCAG 2.1) @@ -38,15 +38,37 @@ - **Synchronisation temps réel** avec le backend - **Cache intelligent** pour performance optimale -## 🚀 Installation +## 🚀 Installation et Configuration -This project is a starting point for a Flutter application. +### **Prérequis** +- Flutter SDK 3.5.3+ +- Dart SDK 3.5.3+ +- Android Studio / VS Code +- Émulateur Android ou appareil physique -A few resources to get you started if this is your first Flutter project: +### **Installation** +```bash +# Cloner le projet +git clone +cd unionflow-mobile-apps -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +# Installer les dépendances +flutter pub get -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +# Générer les fichiers de code (DI) +flutter packages pub run build_runner build + +# Lancer l'application +flutter run +``` + +### **Configuration API** +Modifier l'URL de base dans `lib/core/network/dio_client.dart` : +```dart +baseUrl: 'http://your-api-url:8081', // Remplacer par votre URL API +``` + +### **Scripts utiles** +- `flutter test` - Exécuter les tests +- `flutter analyze` - Analyser le code +- `flutter build apk` - Construire l'APK Android diff --git a/unionflow-mobile-apps/lib/core/auth/services/auth_service.dart b/unionflow-mobile-apps/lib/core/auth/services/auth_service.dart index 0202998..2745ffc 100644 --- a/unionflow-mobile-apps/lib/core/auth/services/auth_service.dart +++ b/unionflow-mobile-apps/lib/core/auth/services/auth_service.dart @@ -3,7 +3,7 @@ import 'package:injectable/injectable.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; import '../models/auth_state.dart'; import '../models/login_request.dart'; -import '../models/login_response.dart'; + import '../models/user_info.dart'; import '../storage/secure_token_storage.dart'; import 'auth_api_service.dart'; diff --git a/unionflow-mobile-apps/lib/core/auth/services/temp_auth_service.dart b/unionflow-mobile-apps/lib/core/auth/services/temp_auth_service.dart index aebdc24..702d7f8 100644 --- a/unionflow-mobile-apps/lib/core/auth/services/temp_auth_service.dart +++ b/unionflow-mobile-apps/lib/core/auth/services/temp_auth_service.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; + import '../models/auth_state.dart'; import '../models/login_request.dart'; import '../models/user_info.dart'; diff --git a/unionflow-mobile-apps/lib/core/auth/services/ultra_simple_auth_service.dart b/unionflow-mobile-apps/lib/core/auth/services/ultra_simple_auth_service.dart index 2a8612c..9511bba 100644 --- a/unionflow-mobile-apps/lib/core/auth/services/ultra_simple_auth_service.dart +++ b/unionflow-mobile-apps/lib/core/auth/services/ultra_simple_auth_service.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; + import '../models/auth_state.dart'; import '../models/login_request.dart'; import '../models/user_info.dart'; diff --git a/unionflow-mobile-apps/lib/core/auth/storage/secure_token_storage.dart b/unionflow-mobile-apps/lib/core/auth/storage/secure_token_storage.dart index bc115c6..b0dd579 100644 --- a/unionflow-mobile-apps/lib/core/auth/storage/secure_token_storage.dart +++ b/unionflow-mobile-apps/lib/core/auth/storage/secure_token_storage.dart @@ -83,7 +83,8 @@ class SecureTokenStorage { /// Récupère la date d'expiration du refresh token Future getRefreshTokenExpirationDate() async { try { - final expiresAtString = await _storage.read(key: _refreshExpiresAtKey); + final prefs = await _prefs; + final expiresAtString = prefs.getString(_refreshExpiresAtKey); if (expiresAtString == null) return null; return DateTime.parse(expiresAtString); @@ -133,9 +134,10 @@ class SecureTokenStorage { /// Met à jour le token d'accès Future updateAccessToken(String accessToken, DateTime expiresAt) async { try { + final prefs = await _prefs; await Future.wait([ - _storage.write(key: _accessTokenKey, value: accessToken), - _storage.write(key: _expiresAtKey, value: expiresAt.toIso8601String()), + prefs.setString(_accessTokenKey, accessToken), + prefs.setString(_expiresAtKey, expiresAt.toIso8601String()), ]); } catch (e) { throw StorageException('Erreur lors de la mise à jour du token d\'accès: $e'); @@ -145,8 +147,9 @@ class SecureTokenStorage { /// Vérifie si les données d'authentification existent Future hasAuthData() async { try { - final accessToken = await _storage.read(key: _accessTokenKey); - final refreshToken = await _storage.read(key: _refreshTokenKey); + final prefs = await _prefs; + final accessToken = prefs.getString(_accessTokenKey); + final refreshToken = prefs.getString(_refreshTokenKey); return accessToken != null && refreshToken != null; } catch (e) { return false; @@ -184,12 +187,13 @@ class SecureTokenStorage { /// Efface toutes les données d'authentification Future clearAuthData() async { try { + final prefs = await _prefs; await Future.wait([ - _storage.delete(key: _accessTokenKey), - _storage.delete(key: _refreshTokenKey), - _storage.delete(key: _userInfoKey), - _storage.delete(key: _expiresAtKey), - _storage.delete(key: _refreshExpiresAtKey), + prefs.remove(_accessTokenKey), + prefs.remove(_refreshTokenKey), + prefs.remove(_userInfoKey), + prefs.remove(_expiresAtKey), + prefs.remove(_refreshExpiresAtKey), ]); } catch (e) { throw StorageException('Erreur lors de l\'effacement des données d\'authentification: $e'); @@ -199,7 +203,8 @@ class SecureTokenStorage { /// Active/désactive l'authentification biométrique Future setBiometricEnabled(bool enabled) async { try { - await _storage.write(key: _biometricEnabledKey, value: enabled.toString()); + final prefs = await _prefs; + await prefs.setBool(_biometricEnabledKey, enabled); } catch (e) { throw StorageException('Erreur lors de la configuration biométrique: $e'); } @@ -208,8 +213,8 @@ class SecureTokenStorage { /// Vérifie si l'authentification biométrique est activée Future isBiometricEnabled() async { try { - final enabled = await _storage.read(key: _biometricEnabledKey); - return enabled == 'true'; + final prefs = await _prefs; + return prefs.getBool(_biometricEnabledKey) ?? false; } catch (e) { return false; } @@ -218,7 +223,8 @@ class SecureTokenStorage { /// Efface toutes les données stockées Future clearAll() async { try { - await _storage.deleteAll(); + final prefs = await _prefs; + await prefs.clear(); } catch (e) { throw StorageException('Erreur lors de l\'effacement de toutes les données: $e'); } @@ -227,8 +233,8 @@ class SecureTokenStorage { /// Vérifie si le stockage sécurisé est disponible Future isAvailable() async { try { - await _storage.containsKey(key: 'test'); - return true; + final prefs = await _prefs; + return prefs.containsKey('test'); } catch (e) { return false; } diff --git a/unionflow-mobile-apps/lib/core/constants/app_constants.dart b/unionflow-mobile-apps/lib/core/constants/app_constants.dart index baae321..72cb8c5 100644 --- a/unionflow-mobile-apps/lib/core/constants/app_constants.dart +++ b/unionflow-mobile-apps/lib/core/constants/app_constants.dart @@ -1,7 +1,7 @@ class AppConstants { // API Configuration - static const String baseUrl = 'http://localhost:8099'; // Backend UnionFlow - static const String apiVersion = '/api/v1'; + static const String baseUrl = 'http://192.168.1.13:8080'; // Backend UnionFlow + static const String apiVersion = '/api'; // Timeout static const Duration connectTimeout = Duration(seconds: 30); diff --git a/unionflow-mobile-apps/lib/core/di/injection.config.dart b/unionflow-mobile-apps/lib/core/di/injection.config.dart index 686915d..de6d991 100644 --- a/unionflow-mobile-apps/lib/core/di/injection.config.dart +++ b/unionflow-mobile-apps/lib/core/di/injection.config.dart @@ -4,42 +4,60 @@ // InjectableConfigGenerator // ************************************************************************** -// ignore_for_file: unnecessary_lambdas -// ignore_for_file: lines_longer_than_80_chars +// ignore_for_file: type=lint // coverage:ignore-file -import 'package:get_it/get_it.dart' as _i1; -import 'package:injectable/injectable.dart' as _i2; +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:get_it/get_it.dart' as _i174; +import 'package:injectable/injectable.dart' as _i526; +import 'package:unionflow_mobile_apps/core/auth/bloc/auth_bloc.dart' as _i635; +import 'package:unionflow_mobile_apps/core/auth/services/auth_api_service.dart' + as _i705; +import 'package:unionflow_mobile_apps/core/auth/services/auth_service.dart' + as _i423; +import 'package:unionflow_mobile_apps/core/auth/storage/secure_token_storage.dart' + as _i394; +import 'package:unionflow_mobile_apps/core/network/auth_interceptor.dart' + as _i772; +import 'package:unionflow_mobile_apps/core/network/dio_client.dart' as _i978; +import 'package:unionflow_mobile_apps/core/services/api_service.dart' as _i238; +import 'package:unionflow_mobile_apps/features/members/data/repositories/membre_repository_impl.dart' + as _i108; +import 'package:unionflow_mobile_apps/features/members/domain/repositories/membre_repository.dart' + as _i930; +import 'package:unionflow_mobile_apps/features/members/presentation/bloc/membres_bloc.dart' + as _i41; -import '../auth/bloc/auth_bloc.dart' as _i7; -import '../auth/services/auth_api_service.dart' as _i4; -import '../auth/services/auth_service.dart' as _i6; -import '../auth/storage/secure_token_storage.dart' as _i3; -import '../network/auth_interceptor.dart' as _i5; -import '../network/dio_client.dart' as _i8; - -extension GetItInjectableX on _i1.GetIt { - // initializes the registration of main-scope dependencies inside of GetIt - Future<_i1.GetIt> init({ +extension GetItInjectableX on _i174.GetIt { +// initializes the registration of main-scope dependencies inside of GetIt + _i174.GetIt init({ String? environment, - _i2.EnvironmentFilter? environmentFilter, - }) async { - final gh = _i2.GetItHelper( + _i526.EnvironmentFilter? environmentFilter, + }) { + final gh = _i526.GetItHelper( this, environment, environmentFilter, ); - gh.singleton<_i3.SecureTokenStorage>(() => _i3.SecureTokenStorage()); - gh.singleton<_i8.DioClient>(() => _i8.DioClient()); - gh.singleton<_i5.AuthInterceptor>(() => _i5.AuthInterceptor(gh<_i3.SecureTokenStorage>())); - gh.singleton<_i4.AuthApiService>(() => _i4.AuthApiService(gh<_i8.DioClient>())); - gh.singleton<_i6.AuthService>(() => _i6.AuthService( - gh<_i3.SecureTokenStorage>(), - gh<_i4.AuthApiService>(), - gh<_i5.AuthInterceptor>(), - gh<_i8.DioClient>(), - )); - gh.singleton<_i7.AuthBloc>(() => _i7.AuthBloc(gh<_i6.AuthService>())); + gh.singleton<_i394.SecureTokenStorage>(() => _i394.SecureTokenStorage()); + gh.singleton<_i978.DioClient>(() => _i978.DioClient()); + gh.singleton<_i705.AuthApiService>( + () => _i705.AuthApiService(gh<_i978.DioClient>())); + gh.singleton<_i238.ApiService>( + () => _i238.ApiService(gh<_i978.DioClient>())); + gh.singleton<_i772.AuthInterceptor>( + () => _i772.AuthInterceptor(gh<_i394.SecureTokenStorage>())); + gh.lazySingleton<_i930.MembreRepository>( + () => _i108.MembreRepositoryImpl(gh<_i238.ApiService>())); + gh.factory<_i41.MembresBloc>( + () => _i41.MembresBloc(gh<_i930.MembreRepository>())); + 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>())); return this; } -} \ No newline at end of file +} diff --git a/unionflow-mobile-apps/lib/core/di/injection.dart b/unionflow-mobile-apps/lib/core/di/injection.dart index 80a78dc..50034a2 100644 --- a/unionflow-mobile-apps/lib/core/di/injection.dart +++ b/unionflow-mobile-apps/lib/core/di/injection.dart @@ -9,7 +9,7 @@ final GetIt getIt = GetIt.instance; /// Configure l'injection de dépendances @InjectableInit() Future configureDependencies() async { - await getIt.init(); + getIt.init(); } /// Réinitialise les dépendances (utile pour les tests) diff --git a/unionflow-mobile-apps/lib/core/models/membre_model.dart b/unionflow-mobile-apps/lib/core/models/membre_model.dart new file mode 100644 index 0000000..9fd2631 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/membre_model.dart @@ -0,0 +1,186 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'membre_model.g.dart'; + +/// Modèle de données pour un membre UnionFlow +/// Aligné avec MembreDTO du serveur API +@JsonSerializable() +class MembreModel extends Equatable { + /// ID unique du membre + final String? id; + + /// Numéro unique du membre (format: UF-YYYY-XXXXXXXX) + @JsonKey(name: 'numeroMembre') + final String numeroMembre; + + /// Nom de famille du membre + final String nom; + + /// Prénom du membre + final String prenom; + + /// Adresse email + final String email; + + /// Numéro de téléphone + final String telephone; + + /// Date de naissance + @JsonKey(name: 'dateNaissance') + final DateTime? dateNaissance; + + /// Adresse complète + final String? adresse; + + /// Ville + final String? ville; + + /// Code postal + @JsonKey(name: 'codePostal') + final String? codePostal; + + /// Pays + final String? pays; + + /// Profession + final String? profession; + + /// Statut du membre (ACTIF, INACTIF, SUSPENDU) + final String statut; + + /// Date d'adhésion + @JsonKey(name: 'dateAdhesion') + final DateTime dateAdhesion; + + /// Date de création + @JsonKey(name: 'dateCreation') + final DateTime dateCreation; + + /// Date de dernière modification + @JsonKey(name: 'dateModification') + final DateTime? dateModification; + + /// Indique si le membre est actif + final bool actif; + + /// Version pour optimistic locking + final int version; + + const MembreModel({ + this.id, + required this.numeroMembre, + required this.nom, + required this.prenom, + required this.email, + required this.telephone, + this.dateNaissance, + this.adresse, + this.ville, + this.codePostal, + this.pays, + this.profession, + required this.statut, + required this.dateAdhesion, + required this.dateCreation, + this.dateModification, + required this.actif, + required this.version, + }); + + /// Constructeur depuis JSON + factory MembreModel.fromJson(Map json) => + _$MembreModelFromJson(json); + + /// Conversion vers JSON + Map toJson() => _$MembreModelToJson(this); + + /// Nom complet du membre + String get nomComplet => '$prenom $nom'; + + /// Initiales du membre + String get initiales { + final prenomInitial = prenom.isNotEmpty ? prenom[0].toUpperCase() : ''; + final nomInitial = nom.isNotEmpty ? nom[0].toUpperCase() : ''; + return '$prenomInitial$nomInitial'; + } + + /// Adresse complète formatée + String get adresseComplete { + final parts = []; + if (adresse?.isNotEmpty == true) parts.add(adresse!); + if (ville?.isNotEmpty == true) parts.add(ville!); + if (codePostal?.isNotEmpty == true) parts.add(codePostal!); + if (pays?.isNotEmpty == true) parts.add(pays!); + return parts.join(', '); + } + + /// Copie avec modifications + MembreModel copyWith({ + String? id, + String? numeroMembre, + String? nom, + String? prenom, + String? email, + String? telephone, + DateTime? dateNaissance, + String? adresse, + String? ville, + String? codePostal, + String? pays, + String? profession, + String? statut, + DateTime? dateAdhesion, + DateTime? dateCreation, + DateTime? dateModification, + bool? actif, + int? version, + }) { + return MembreModel( + id: id ?? this.id, + numeroMembre: numeroMembre ?? this.numeroMembre, + nom: nom ?? this.nom, + prenom: prenom ?? this.prenom, + email: email ?? this.email, + telephone: telephone ?? this.telephone, + dateNaissance: dateNaissance ?? this.dateNaissance, + adresse: adresse ?? this.adresse, + ville: ville ?? this.ville, + codePostal: codePostal ?? this.codePostal, + pays: pays ?? this.pays, + profession: profession ?? this.profession, + statut: statut ?? this.statut, + dateAdhesion: dateAdhesion ?? this.dateAdhesion, + dateCreation: dateCreation ?? this.dateCreation, + dateModification: dateModification ?? this.dateModification, + actif: actif ?? this.actif, + version: version ?? this.version, + ); + } + + @override + List get props => [ + id, + numeroMembre, + nom, + prenom, + email, + telephone, + dateNaissance, + adresse, + ville, + codePostal, + pays, + profession, + statut, + dateAdhesion, + dateCreation, + dateModification, + actif, + version, + ]; + + @override + String toString() => 'MembreModel(id: $id, numeroMembre: $numeroMembre, ' + 'nomComplet: $nomComplet, email: $email, statut: $statut)'; +} diff --git a/unionflow-mobile-apps/lib/core/models/membre_model.g.dart b/unionflow-mobile-apps/lib/core/models/membre_model.g.dart new file mode 100644 index 0000000..e7f4dbb --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/membre_model.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'membre_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MembreModel _$MembreModelFromJson(Map json) => MembreModel( + id: json['id'] as String?, + numeroMembre: json['numeroMembre'] as String, + nom: json['nom'] as String, + prenom: json['prenom'] as String, + email: json['email'] as String, + telephone: json['telephone'] as String, + dateNaissance: json['dateNaissance'] == null + ? null + : DateTime.parse(json['dateNaissance'] as String), + adresse: json['adresse'] as String?, + ville: json['ville'] as String?, + codePostal: json['codePostal'] as String?, + pays: json['pays'] as String?, + profession: json['profession'] as String?, + statut: json['statut'] as String, + dateAdhesion: DateTime.parse(json['dateAdhesion'] as String), + dateCreation: DateTime.parse(json['dateCreation'] as String), + dateModification: json['dateModification'] == null + ? null + : DateTime.parse(json['dateModification'] as String), + actif: json['actif'] as bool, + version: (json['version'] as num).toInt(), + ); + +Map _$MembreModelToJson(MembreModel instance) => + { + 'id': instance.id, + 'numeroMembre': instance.numeroMembre, + 'nom': instance.nom, + 'prenom': instance.prenom, + 'email': instance.email, + 'telephone': instance.telephone, + 'dateNaissance': instance.dateNaissance?.toIso8601String(), + 'adresse': instance.adresse, + 'ville': instance.ville, + 'codePostal': instance.codePostal, + 'pays': instance.pays, + 'profession': instance.profession, + 'statut': instance.statut, + 'dateAdhesion': instance.dateAdhesion.toIso8601String(), + 'dateCreation': instance.dateCreation.toIso8601String(), + 'dateModification': instance.dateModification?.toIso8601String(), + 'actif': instance.actif, + 'version': instance.version, + }; diff --git a/unionflow-mobile-apps/lib/core/models/wave_checkout_session_model.dart b/unionflow-mobile-apps/lib/core/models/wave_checkout_session_model.dart new file mode 100644 index 0000000..0e66d8d --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/wave_checkout_session_model.dart @@ -0,0 +1,206 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'wave_checkout_session_model.g.dart'; + +/// Modèle pour les sessions de paiement Wave Money +/// Aligné avec WaveCheckoutSessionDTO du serveur API +@JsonSerializable() +class WaveCheckoutSessionModel extends Equatable { + /// ID unique de la session + final String? id; + + /// ID de la session Wave (retourné par l'API Wave) + @JsonKey(name: 'waveSessionId') + final String waveSessionId; + + /// URL de la session de paiement Wave + @JsonKey(name: 'waveUrl') + final String? waveUrl; + + /// Montant du paiement + final double montant; + + /// Devise (XOF pour la Côte d'Ivoire) + final String devise; + + /// URL de succès (redirection après paiement réussi) + @JsonKey(name: 'successUrl') + final String successUrl; + + /// URL d'erreur (redirection après échec) + @JsonKey(name: 'errorUrl') + final String errorUrl; + + /// Statut de la session + final String statut; + + /// ID de l'organisation qui effectue le paiement + @JsonKey(name: 'organisationId') + final String? organisationId; + + /// Nom de l'organisation + @JsonKey(name: 'nomOrganisation') + final String? nomOrganisation; + + /// ID du membre qui effectue le paiement + @JsonKey(name: 'membreId') + final String? membreId; + + /// Nom du membre + @JsonKey(name: 'nomMembre') + final String? nomMembre; + + /// Type de paiement (COTISATION, ADHESION, AIDE, EVENEMENT) + @JsonKey(name: 'typePaiement') + final String? typePaiement; + + /// Description du paiement + final String? description; + + /// Référence externe + @JsonKey(name: 'referenceExterne') + final String? referenceExterne; + + /// Date de création + @JsonKey(name: 'dateCreation') + final DateTime dateCreation; + + /// Date d'expiration + @JsonKey(name: 'dateExpiration') + final DateTime? dateExpiration; + + /// Date de dernière modification + @JsonKey(name: 'dateModification') + final DateTime? dateModification; + + /// Indique si la session est active + final bool actif; + + /// Version pour optimistic locking + final int version; + + const WaveCheckoutSessionModel({ + this.id, + required this.waveSessionId, + this.waveUrl, + required this.montant, + required this.devise, + required this.successUrl, + required this.errorUrl, + required this.statut, + this.organisationId, + this.nomOrganisation, + this.membreId, + this.nomMembre, + this.typePaiement, + this.description, + this.referenceExterne, + required this.dateCreation, + this.dateExpiration, + this.dateModification, + required this.actif, + required this.version, + }); + + /// Constructeur depuis JSON + factory WaveCheckoutSessionModel.fromJson(Map json) => + _$WaveCheckoutSessionModelFromJson(json); + + /// Conversion vers JSON + Map toJson() => _$WaveCheckoutSessionModelToJson(this); + + /// Montant formaté avec devise + String get montantFormate => '${montant.toStringAsFixed(0)} $devise'; + + /// Indique si la session est expirée + bool get estExpiree { + if (dateExpiration == null) return false; + return DateTime.now().isAfter(dateExpiration!); + } + + /// Indique si la session est en attente + bool get estEnAttente => statut == 'PENDING' || statut == 'EN_ATTENTE'; + + /// Indique si la session est réussie + bool get estReussie => statut == 'SUCCESS' || statut == 'REUSSIE'; + + /// Indique si la session a échoué + bool get aEchoue => statut == 'FAILED' || statut == 'ECHEC'; + + /// Copie avec modifications + WaveCheckoutSessionModel copyWith({ + String? id, + String? waveSessionId, + String? waveUrl, + double? montant, + String? devise, + String? successUrl, + String? errorUrl, + String? statut, + String? organisationId, + String? nomOrganisation, + String? membreId, + String? nomMembre, + String? typePaiement, + String? description, + String? referenceExterne, + DateTime? dateCreation, + DateTime? dateExpiration, + DateTime? dateModification, + bool? actif, + int? version, + }) { + return WaveCheckoutSessionModel( + id: id ?? this.id, + waveSessionId: waveSessionId ?? this.waveSessionId, + waveUrl: waveUrl ?? this.waveUrl, + montant: montant ?? this.montant, + devise: devise ?? this.devise, + successUrl: successUrl ?? this.successUrl, + errorUrl: errorUrl ?? this.errorUrl, + statut: statut ?? this.statut, + organisationId: organisationId ?? this.organisationId, + nomOrganisation: nomOrganisation ?? this.nomOrganisation, + membreId: membreId ?? this.membreId, + nomMembre: nomMembre ?? this.nomMembre, + typePaiement: typePaiement ?? this.typePaiement, + description: description ?? this.description, + referenceExterne: referenceExterne ?? this.referenceExterne, + dateCreation: dateCreation ?? this.dateCreation, + dateExpiration: dateExpiration ?? this.dateExpiration, + dateModification: dateModification ?? this.dateModification, + actif: actif ?? this.actif, + version: version ?? this.version, + ); + } + + @override + List get props => [ + id, + waveSessionId, + waveUrl, + montant, + devise, + successUrl, + errorUrl, + statut, + organisationId, + nomOrganisation, + membreId, + nomMembre, + typePaiement, + description, + referenceExterne, + dateCreation, + dateExpiration, + dateModification, + actif, + version, + ]; + + @override + String toString() => 'WaveCheckoutSessionModel(id: $id, ' + 'waveSessionId: $waveSessionId, montant: $montantFormate, ' + 'statut: $statut, typePaiement: $typePaiement)'; +} diff --git a/unionflow-mobile-apps/lib/core/models/wave_checkout_session_model.g.dart b/unionflow-mobile-apps/lib/core/models/wave_checkout_session_model.g.dart new file mode 100644 index 0000000..a19de74 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/wave_checkout_session_model.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wave_checkout_session_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +WaveCheckoutSessionModel _$WaveCheckoutSessionModelFromJson( + Map json) => + WaveCheckoutSessionModel( + id: json['id'] as String?, + waveSessionId: json['waveSessionId'] as String, + waveUrl: json['waveUrl'] as String?, + montant: (json['montant'] as num).toDouble(), + devise: json['devise'] as String, + successUrl: json['successUrl'] as String, + errorUrl: json['errorUrl'] as String, + statut: json['statut'] as String, + organisationId: json['organisationId'] as String?, + nomOrganisation: json['nomOrganisation'] as String?, + membreId: json['membreId'] as String?, + nomMembre: json['nomMembre'] as String?, + typePaiement: json['typePaiement'] as String?, + description: json['description'] as String?, + referenceExterne: json['referenceExterne'] as String?, + dateCreation: DateTime.parse(json['dateCreation'] as String), + dateExpiration: json['dateExpiration'] == null + ? null + : DateTime.parse(json['dateExpiration'] as String), + dateModification: json['dateModification'] == null + ? null + : DateTime.parse(json['dateModification'] as String), + actif: json['actif'] as bool, + version: (json['version'] as num).toInt(), + ); + +Map _$WaveCheckoutSessionModelToJson( + WaveCheckoutSessionModel instance) => + { + 'id': instance.id, + 'waveSessionId': instance.waveSessionId, + 'waveUrl': instance.waveUrl, + 'montant': instance.montant, + 'devise': instance.devise, + 'successUrl': instance.successUrl, + 'errorUrl': instance.errorUrl, + 'statut': instance.statut, + 'organisationId': instance.organisationId, + 'nomOrganisation': instance.nomOrganisation, + 'membreId': instance.membreId, + 'nomMembre': instance.nomMembre, + 'typePaiement': instance.typePaiement, + 'description': instance.description, + 'referenceExterne': instance.referenceExterne, + 'dateCreation': instance.dateCreation.toIso8601String(), + 'dateExpiration': instance.dateExpiration?.toIso8601String(), + 'dateModification': instance.dateModification?.toIso8601String(), + 'actif': instance.actif, + 'version': instance.version, + }; diff --git a/unionflow-mobile-apps/lib/core/network/dio_client.dart b/unionflow-mobile-apps/lib/core/network/dio_client.dart index 675830c..abbbea4 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://localhost:8081', // Adresse de votre API Quarkus + baseUrl: 'http://192.168.1.13: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 new file mode 100644 index 0000000..1f345f9 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/services/api_service.dart @@ -0,0 +1,214 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import '../models/membre_model.dart'; +import '../models/wave_checkout_session_model.dart'; +import '../network/dio_client.dart'; + +/// Service API principal pour communiquer avec le serveur UnionFlow +@singleton +class ApiService { + final DioClient _dioClient; + + ApiService(this._dioClient); + + Dio get _dio => _dioClient.dio; + + // ======================================== + // MEMBRES + // ======================================== + + /// Récupère la liste de tous les membres actifs + Future> getMembres() async { + try { + final response = await _dio.get('/api/membres'); + + if (response.data is List) { + return (response.data as List) + .map((json) => MembreModel.fromJson(json as Map)) + .toList(); + } + + throw Exception('Format de réponse invalide pour la liste des membres'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération des membres'); + } + } + + /// Récupère un membre par son ID + Future getMembreById(String id) async { + try { + final response = await _dio.get('/api/membres/$id'); + return MembreModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération du membre'); + } + } + + /// Crée un nouveau membre + Future createMembre(MembreModel membre) async { + try { + final response = await _dio.post( + '/api/membres', + data: membre.toJson(), + ); + return MembreModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la création du membre'); + } + } + + /// Met à jour un membre existant + Future updateMembre(String id, MembreModel membre) async { + try { + final response = await _dio.put( + '/api/membres/$id', + data: membre.toJson(), + ); + return MembreModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la mise à jour du membre'); + } + } + + /// Désactive un membre + Future deleteMembre(String id) async { + try { + await _dio.delete('/api/membres/$id'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la suppression du membre'); + } + } + + /// Recherche des membres par nom ou prénom + Future> searchMembres(String query) async { + try { + final response = await _dio.get( + '/api/membres/recherche', + queryParameters: {'q': query}, + ); + + if (response.data is List) { + return (response.data as List) + .map((json) => MembreModel.fromJson(json as Map)) + .toList(); + } + + throw Exception('Format de réponse invalide pour la recherche'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la recherche de membres'); + } + } + + /// Récupère les statistiques des membres + Future> getMembresStats() async { + try { + final response = await _dio.get('/api/membres/stats'); + return response.data as Map; + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération des statistiques'); + } + } + + // ======================================== + // PAIEMENTS WAVE + // ======================================== + + /// Crée une session de paiement Wave + Future createWaveSession({ + required double montant, + required String devise, + required String successUrl, + required String errorUrl, + String? organisationId, + String? membreId, + String? typePaiement, + String? description, + }) async { + try { + final response = await _dio.post( + '/api/paiements/wave/sessions', + data: { + 'montant': montant, + 'devise': devise, + 'successUrl': successUrl, + 'errorUrl': errorUrl, + if (organisationId != null) 'organisationId': organisationId, + if (membreId != null) 'membreId': membreId, + if (typePaiement != null) 'typePaiement': typePaiement, + if (description != null) 'description': description, + }, + ); + return WaveCheckoutSessionModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la création de la session Wave'); + } + } + + /// Récupère une session de paiement Wave par son ID + Future getWaveSession(String sessionId) async { + try { + final response = await _dio.get('/api/paiements/wave/sessions/$sessionId'); + return WaveCheckoutSessionModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération de la session Wave'); + } + } + + /// Vérifie le statut d'une session de paiement Wave + Future checkWaveSessionStatus(String sessionId) async { + try { + final response = await _dio.get('/api/paiements/wave/sessions/$sessionId/status'); + return response.data['statut'] as String; + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la vérification du statut Wave'); + } + } + + // ======================================== + // GESTION DES ERREURS + // ======================================== + + /// Gère les exceptions Dio et les convertit en messages d'erreur appropriés + Exception _handleDioException(DioException e, String defaultMessage) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return Exception('Délai d\'attente dépassé. Vérifiez votre connexion internet.'); + + case DioExceptionType.badResponse: + final statusCode = e.response?.statusCode; + final responseData = e.response?.data; + + if (statusCode == 400) { + if (responseData is Map && responseData.containsKey('message')) { + return Exception(responseData['message']); + } + return Exception('Données invalides'); + } else if (statusCode == 401) { + return Exception('Non autorisé. Veuillez vous reconnecter.'); + } else if (statusCode == 403) { + return Exception('Accès interdit'); + } else if (statusCode == 404) { + return Exception('Ressource non trouvée'); + } else if (statusCode == 500) { + return Exception('Erreur serveur. Veuillez réessayer plus tard.'); + } + + return Exception('$defaultMessage (Code: $statusCode)'); + + case DioExceptionType.cancel: + return Exception('Requête annulée'); + + case DioExceptionType.connectionError: + return Exception('Erreur de connexion. Vérifiez votre connexion internet.'); + + case DioExceptionType.badCertificate: + return Exception('Certificat SSL invalide'); + + case DioExceptionType.unknown: + default: + return Exception(defaultMessage); + } + } +} diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page.dart b/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page.dart index 868efa6..c8371b2 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page.dart @@ -6,7 +6,6 @@ import '../../../../core/auth/bloc/auth_event.dart'; import '../../../../core/auth/models/auth_state.dart'; import '../../../../core/auth/models/login_request.dart'; import '../../../../shared/theme/app_theme.dart'; -import '../../../../shared/widgets/buttons/buttons.dart'; import '../widgets/login_form.dart'; import '../widgets/login_header.dart'; import '../widgets/login_footer.dart'; @@ -48,12 +47,12 @@ class _LoginPageState extends State duration: const Duration(milliseconds: 1200), vsync: this, ); - + _shakeController = AnimationController( duration: const Duration(milliseconds: 600), vsync: this, ); - + _fadeAnimation = Tween( begin: 0.0, end: 1.0, @@ -61,7 +60,7 @@ class _LoginPageState extends State parent: _animationController, curve: const Interval(0.0, 0.6, curve: Curves.easeOut), )); - + _slideAnimation = Tween( begin: 50.0, end: 0.0, @@ -69,22 +68,23 @@ class _LoginPageState extends State parent: _animationController, curve: const Interval(0.2, 0.8, curve: Curves.easeOut), )); - + _shakeAnimation = Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: _shakeController, - curve: Curves.elasticInOut, + curve: Curves.elasticIn, )); } void _startEntryAnimation() { - Future.delayed(const Duration(milliseconds: 100), () { - if (mounted) { - _animationController.forward(); - } - }); + _animationController.forward(); + } + + void _startShakeAnimation() { + _shakeController.reset(); + _shakeController.forward(); } @override @@ -100,170 +100,187 @@ class _LoginPageState extends State Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.backgroundLight, - body: BlocListener( - listener: _handleAuthStateChange, - child: SafeArea( - child: AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return FadeTransition( - opacity: _fadeAnimation, - child: Transform.translate( - offset: Offset(0, _slideAnimation.value), - child: _buildLoginContent(), - ), - ); - }, - ), - ), + body: BlocConsumer( + listener: (context, state) { + setState(() { + _isLoading = state.status == AuthStatus.checking; + }); + + if (state.status == AuthStatus.error) { + _startShakeAnimation(); + _showErrorSnackBar(state.errorMessage ?? 'Erreur de connexion'); + } else if (state.status == AuthStatus.authenticated) { + _showSuccessSnackBar('Connexion réussie !'); + } + }, + builder: (context, state) { + return SafeArea( + child: _buildLoginContent(), + ); + }, ), ); } Widget _buildLoginContent() { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - const SizedBox(height: 60), - - // Header avec logo et titre - LoginHeader( - onAnimationComplete: () {}, - ), - - const SizedBox(height: 60), - - // Formulaire de connexion - AnimatedBuilder( - animation: _shakeAnimation, - builder: (context, child) { - return Transform.translate( - offset: Offset( - _shakeAnimation.value * 10 * - (1 - _shakeAnimation.value) * - (1 - _shakeAnimation.value), - 0, - ), - child: LoginForm( - formKey: _formKey, - emailController: _emailController, - passwordController: _passwordController, - obscurePassword: _obscurePassword, - rememberMe: _rememberMe, - isLoading: _isLoading, - onObscureToggle: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - HapticFeedback.selectionClick(); - }, - onRememberMeToggle: (value) { - setState(() { - _rememberMe = value; - }); - HapticFeedback.selectionClick(); - }, - onSubmit: _handleLogin, - ), + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, _slideAnimation.value), + child: Opacity( + opacity: _fadeAnimation.value, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 60), + + // Header avec logo et titre + const LoginHeader(), + + const SizedBox(height: 40), + + // Formulaire de connexion + AnimatedBuilder( + animation: _shakeAnimation, + builder: (context, child) { + return Transform.translate( + offset: Offset( + _shakeAnimation.value * 10 * + (1 - _shakeAnimation.value) * + ((_shakeAnimation.value * 10).round() % 2 == 0 ? 1 : -1), + 0, + ), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: LoginForm( + formKey: _formKey, + emailController: _emailController, + passwordController: _passwordController, + obscurePassword: _obscurePassword, + rememberMe: _rememberMe, + isLoading: _isLoading, + onObscureToggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + HapticFeedback.selectionClick(); + }, + onRememberMeToggle: (value) { + setState(() { + _rememberMe = value; + }); + HapticFeedback.selectionClick(); + }, + onSubmit: _handleLogin, + ), + ), + ), + ); + }, + ), + + const SizedBox(height: 40), + + // Footer avec liens et informations + const LoginFooter(), + + const SizedBox(height: 20), + ], ), ), ), - - const SizedBox(height: 40), - - // Footer avec liens et informations - const LoginFooter(), - - const SizedBox(height: 20), - ], - ), + ); + }, ); } - void _handleAuthStateChange(BuildContext context, AuthState state) { - setState(() { - _isLoading = state.isLoading; - }); - - if (state.status == AuthStatus.authenticated) { - // Connexion réussie - navigation gérée par l'app principal - _showSuccessMessage(); - HapticFeedback.heavyImpact(); - - } else if (state.status == AuthStatus.error) { - // Erreur de connexion - _handleLoginError(state.errorMessage ?? 'Erreur inconnue'); - - } else if (state.status == AuthStatus.unauthenticated && state.errorMessage != null) { - // Échec de connexion - _handleLoginError(state.errorMessage!); - } - } - void _handleLogin() { if (!_formKey.currentState!.validate()) { - _triggerShakeAnimation(); - HapticFeedback.mediumImpact(); + _startShakeAnimation(); return; } - final email = _emailController.text.trim(); - final password = _passwordController.text; - - if (email.isEmpty || password.isEmpty) { - _showErrorMessage('Veuillez remplir tous les champs'); - _triggerShakeAnimation(); - return; - } - - // Déclencher la connexion + HapticFeedback.lightImpact(); + final loginRequest = LoginRequest( - email: email, - password: password, + email: _emailController.text.trim(), + password: _passwordController.text, rememberMe: _rememberMe, ); context.read().add(AuthLoginRequested(loginRequest)); - - // Feedback haptique - HapticFeedback.lightImpact(); } - void _handleLoginError(String errorMessage) { - _showErrorMessage(errorMessage); - _triggerShakeAnimation(); - HapticFeedback.mediumImpact(); - - // Effacer l'erreur après affichage - Future.delayed(const Duration(seconds: 3), () { - if (mounted) { - context.read().add(const AuthErrorCleared()); - } - }); - } - - void _triggerShakeAnimation() { - _shakeController.reset(); - _shakeController.forward(); - } - - void _showSuccessMessage() { + void _showErrorSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( children: [ - Icon( - Icons.check_circle, + const Icon( + Icons.error_outline, color: Colors.white, - size: 24, ), const SizedBox(width: 12), - const Text( - 'Connexion réussie !', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, + Expanded( + child: Text( + message, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + backgroundColor: AppTheme.errorColor, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 4), + action: SnackBarAction( + label: 'Fermer', + textColor: Colors.white, + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + ), + ); + } + + void _showSuccessSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon( + Icons.check_circle_outline, + color: Colors.white, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), ), ], @@ -278,44 +295,4 @@ class _LoginPageState extends State ), ); } - - void _showErrorMessage(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - Icon( - Icons.error_outline, - color: Colors.white, - size: 24, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), - ], - ), - backgroundColor: AppTheme.errorColor, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - margin: const EdgeInsets.all(16), - duration: const Duration(seconds: 4), - action: SnackBarAction( - label: 'OK', - textColor: Colors.white, - onPressed: () { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - }, - ), - ), - ); - } -} \ No newline at end of file +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/enhanced_dashboard.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/enhanced_dashboard.dart index 6239eff..e81ab12 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/enhanced_dashboard.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/enhanced_dashboard.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import '../../../../shared/theme/app_theme.dart'; -import '../widgets/kpi_card.dart'; + import '../widgets/clickable_kpi_card.dart'; import '../widgets/chart_card.dart'; import '../widgets/activity_feed.dart'; diff --git a/unionflow-mobile-apps/lib/features/members/data/repositories/membre_repository_impl.dart b/unionflow-mobile-apps/lib/features/members/data/repositories/membre_repository_impl.dart new file mode 100644 index 0000000..d89e020 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/data/repositories/membre_repository_impl.dart @@ -0,0 +1,76 @@ +import 'package:injectable/injectable.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../core/services/api_service.dart'; +import '../../domain/repositories/membre_repository.dart'; +import '../../../../core/errors/failures.dart'; + +/// Implémentation du repository des membres +@LazySingleton(as: MembreRepository) +class MembreRepositoryImpl implements MembreRepository { + final ApiService _apiService; + + MembreRepositoryImpl(this._apiService); + + @override + Future> getMembres() async { + try { + return await _apiService.getMembres(); + } catch (e) { + throw ServerFailure(message: e.toString()); + } + } + + @override + Future getMembreById(String id) async { + try { + return await _apiService.getMembreById(id); + } catch (e) { + throw ServerFailure(message: e.toString()); + } + } + + @override + Future createMembre(MembreModel membre) async { + try { + return await _apiService.createMembre(membre); + } catch (e) { + throw ServerFailure(message: e.toString()); + } + } + + @override + Future updateMembre(String id, MembreModel membre) async { + try { + return await _apiService.updateMembre(id, membre); + } catch (e) { + throw ServerFailure(message: e.toString()); + } + } + + @override + Future deleteMembre(String id) async { + try { + await _apiService.deleteMembre(id); + } catch (e) { + throw ServerFailure(message: e.toString()); + } + } + + @override + Future> searchMembres(String query) async { + try { + return await _apiService.searchMembres(query); + } catch (e) { + throw ServerFailure(message: e.toString()); + } + } + + @override + Future> getMembresStats() async { + try { + return await _apiService.getMembresStats(); + } catch (e) { + throw ServerFailure(message: e.toString()); + } + } +} diff --git a/unionflow-mobile-apps/lib/features/members/domain/repositories/membre_repository.dart b/unionflow-mobile-apps/lib/features/members/domain/repositories/membre_repository.dart new file mode 100644 index 0000000..95a0c08 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/domain/repositories/membre_repository.dart @@ -0,0 +1,26 @@ +import '../../../../core/models/membre_model.dart'; + +/// Interface du repository des membres +/// Définit les opérations disponibles pour la gestion des membres +abstract class MembreRepository { + /// Récupère la liste de tous les membres actifs + Future> getMembres(); + + /// Récupère un membre par son ID + Future getMembreById(String id); + + /// Crée un nouveau membre + Future createMembre(MembreModel membre); + + /// Met à jour un membre existant + Future updateMembre(String id, MembreModel membre); + + /// Désactive un membre + Future deleteMembre(String id); + + /// Recherche des membres par nom ou prénom + Future> searchMembres(String query); + + /// Récupère les statistiques des membres + Future> getMembresStats(); +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_bloc.dart b/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_bloc.dart new file mode 100644 index 0000000..c61a6f8 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_bloc.dart @@ -0,0 +1,244 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import '../../domain/repositories/membre_repository.dart'; +import '../../../../core/errors/failures.dart'; +import '../../../../core/models/membre_model.dart'; +import 'membres_event.dart'; +import 'membres_state.dart'; + +/// BLoC pour la gestion des membres +@injectable +class MembresBloc extends Bloc { + final MembreRepository _membreRepository; + + MembresBloc(this._membreRepository) : super(const MembresInitial()) { + // Enregistrement des handlers d'événements + on(_onLoadMembres); + on(_onRefreshMembres); + on(_onSearchMembres); + on(_onLoadMembreById); + on(_onCreateMembre); + on(_onUpdateMembre); + on(_onDeleteMembre); + on(_onLoadMembresStats); + on(_onClearMembresError); + on(_onResetMembresState); + } + + /// Handler pour charger la liste des membres + Future _onLoadMembres( + LoadMembres event, + Emitter emit, + ) async { + emit(const MembresLoading()); + + try { + final membres = await _membreRepository.getMembres(); + emit(MembresLoaded(membres: membres)); + } catch (e) { + final failure = _mapExceptionToFailure(e); + emit(MembresError(failure: failure)); + } + } + + /// Handler pour rafraîchir la liste des membres + Future _onRefreshMembres( + RefreshMembres event, + Emitter emit, + ) async { + // Conserver les données actuelles pendant le refresh + final currentState = state; + List currentMembres = []; + + if (currentState is MembresLoaded) { + currentMembres = currentState.membres; + emit(MembresRefreshing(currentMembres)); + } else { + emit(const MembresLoading()); + } + + try { + final membres = await _membreRepository.getMembres(); + emit(MembresLoaded(membres: membres)); + } catch (e) { + final failure = _mapExceptionToFailure(e); + + // Si on avait des données, les conserver avec l'erreur + if (currentMembres.isNotEmpty) { + emit(MembresErrorWithData( + failure: failure, + membres: currentMembres, + )); + } else { + emit(MembresError(failure: failure)); + } + } + } + + /// Handler pour rechercher des membres + Future _onSearchMembres( + SearchMembres event, + Emitter emit, + ) async { + if (event.query.trim().isEmpty) { + // Si la recherche est vide, recharger tous les membres + add(const LoadMembres()); + return; + } + + emit(const MembresLoading()); + + try { + final membres = await _membreRepository.searchMembres(event.query); + emit(MembresLoaded( + membres: membres, + isSearchResult: true, + searchQuery: event.query, + )); + } catch (e) { + final failure = _mapExceptionToFailure(e); + emit(MembresError(failure: failure)); + } + } + + /// Handler pour charger un membre par ID + Future _onLoadMembreById( + LoadMembreById event, + Emitter emit, + ) async { + emit(const MembresLoading()); + + try { + final membre = await _membreRepository.getMembreById(event.id); + emit(MembreDetailLoaded(membre)); + } catch (e) { + final failure = _mapExceptionToFailure(e); + emit(MembresError(failure: failure)); + } + } + + /// Handler pour créer un membre + Future _onCreateMembre( + CreateMembre event, + Emitter emit, + ) async { + emit(const MembresLoading()); + + try { + final nouveauMembre = await _membreRepository.createMembre(event.membre); + emit(MembreCreated(nouveauMembre)); + + // Recharger la liste après création + add(const LoadMembres()); + } catch (e) { + final failure = _mapExceptionToFailure(e); + emit(MembresError(failure: failure)); + } + } + + /// Handler pour mettre à jour un membre + Future _onUpdateMembre( + UpdateMembre event, + Emitter emit, + ) async { + emit(const MembresLoading()); + + try { + final membreMisAJour = await _membreRepository.updateMembre( + event.id, + event.membre, + ); + emit(MembreUpdated(membreMisAJour)); + + // Recharger la liste après mise à jour + add(const LoadMembres()); + } catch (e) { + final failure = _mapExceptionToFailure(e); + emit(MembresError(failure: failure)); + } + } + + /// Handler pour supprimer un membre + Future _onDeleteMembre( + DeleteMembre event, + Emitter emit, + ) async { + emit(const MembresLoading()); + + try { + await _membreRepository.deleteMembre(event.id); + emit(MembreDeleted(event.id)); + + // Recharger la liste après suppression + add(const LoadMembres()); + } catch (e) { + final failure = _mapExceptionToFailure(e); + emit(MembresError(failure: failure)); + } + } + + /// Handler pour charger les statistiques + Future _onLoadMembresStats( + LoadMembresStats event, + Emitter emit, + ) async { + emit(const MembresLoading()); + + try { + final stats = await _membreRepository.getMembresStats(); + emit(MembresStatsLoaded(stats)); + } catch (e) { + final failure = _mapExceptionToFailure(e); + emit(MembresError(failure: failure)); + } + } + + /// Handler pour effacer les erreurs + void _onClearMembresError( + ClearMembresError event, + Emitter emit, + ) { + final currentState = state; + + if (currentState is MembresError && currentState.previousState != null) { + emit(currentState.previousState!); + } else if (currentState is MembresErrorWithData) { + emit(MembresLoaded( + membres: currentState.membres, + isSearchResult: currentState.isSearchResult, + searchQuery: currentState.searchQuery, + )); + } else { + emit(const MembresInitial()); + } + } + + /// Handler pour réinitialiser l'état + void _onResetMembresState( + ResetMembresState event, + Emitter emit, + ) { + emit(const MembresInitial()); + } + + /// Convertit une exception en Failure approprié + Failure _mapExceptionToFailure(dynamic exception) { + if (exception is Failure) { + return exception; + } + + final message = exception.toString(); + + if (message.contains('connexion') || message.contains('network')) { + return NetworkFailure(message: message); + } else if (message.contains('401') || message.contains('unauthorized')) { + return const AuthFailure(message: 'Session expirée. Veuillez vous reconnecter.'); + } else if (message.contains('400') || message.contains('validation')) { + return ValidationFailure(message: message); + } else if (message.contains('500') || message.contains('server')) { + return ServerFailure(message: message); + } + + return ServerFailure(message: message); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_event.dart b/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_event.dart new file mode 100644 index 0000000..4ded123 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_event.dart @@ -0,0 +1,86 @@ +import 'package:equatable/equatable.dart'; +import '../../../../core/models/membre_model.dart'; + +/// Événements pour le BLoC des membres +abstract class MembresEvent extends Equatable { + const MembresEvent(); + + @override + List get props => []; +} + +/// Événement pour charger la liste des membres +class LoadMembres extends MembresEvent { + const LoadMembres(); +} + +/// Événement pour rafraîchir la liste des membres +class RefreshMembres extends MembresEvent { + const RefreshMembres(); +} + +/// Événement pour rechercher des membres +class SearchMembres extends MembresEvent { + const SearchMembres(this.query); + + final String query; + + @override + List get props => [query]; +} + +/// Événement pour charger un membre spécifique +class LoadMembreById extends MembresEvent { + const LoadMembreById(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Événement pour créer un nouveau membre +class CreateMembre extends MembresEvent { + const CreateMembre(this.membre); + + final MembreModel membre; + + @override + List get props => [membre]; +} + +/// Événement pour mettre à jour un membre +class UpdateMembre extends MembresEvent { + const UpdateMembre(this.id, this.membre); + + final String id; + final MembreModel membre; + + @override + List get props => [id, membre]; +} + +/// Événement pour supprimer un membre +class DeleteMembre extends MembresEvent { + const DeleteMembre(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Événement pour charger les statistiques des membres +class LoadMembresStats extends MembresEvent { + const LoadMembresStats(); +} + +/// Événement pour effacer les erreurs +class ClearMembresError extends MembresEvent { + const ClearMembresError(); +} + +/// Événement pour réinitialiser l'état +class ResetMembresState extends MembresEvent { + const ResetMembresState(); +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_state.dart b/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_state.dart new file mode 100644 index 0000000..7f89955 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_state.dart @@ -0,0 +1,163 @@ +import 'package:equatable/equatable.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../core/errors/failures.dart'; + +/// États pour le BLoC des membres +abstract class MembresState extends Equatable { + const MembresState(); + + @override + List get props => []; +} + +/// État initial +class MembresInitial extends MembresState { + const MembresInitial(); +} + +/// État de chargement +class MembresLoading extends MembresState { + const MembresLoading(); +} + +/// État de chargement avec données existantes (pour le refresh) +class MembresRefreshing extends MembresState { + const MembresRefreshing(this.currentMembres); + + final List currentMembres; + + @override + List get props => [currentMembres]; +} + +/// État de succès avec liste des membres +class MembresLoaded extends MembresState { + const MembresLoaded({ + required this.membres, + this.isSearchResult = false, + this.searchQuery, + }); + + final List membres; + final bool isSearchResult; + final String? searchQuery; + + @override + List get props => [membres, isSearchResult, searchQuery]; + + /// Copie avec modifications + MembresLoaded copyWith({ + List? membres, + bool? isSearchResult, + String? searchQuery, + }) { + return MembresLoaded( + membres: membres ?? this.membres, + isSearchResult: isSearchResult ?? this.isSearchResult, + searchQuery: searchQuery ?? this.searchQuery, + ); + } +} + +/// État de succès pour un membre spécifique +class MembreDetailLoaded extends MembresState { + const MembreDetailLoaded(this.membre); + + final MembreModel membre; + + @override + List get props => [membre]; +} + +/// État de succès pour les statistiques +class MembresStatsLoaded extends MembresState { + const MembresStatsLoaded(this.stats); + + final Map stats; + + @override + List get props => [stats]; +} + +/// État de succès pour la création d'un membre +class MembreCreated extends MembresState { + const MembreCreated(this.membre); + + final MembreModel membre; + + @override + List get props => [membre]; +} + +/// État de succès pour la mise à jour d'un membre +class MembreUpdated extends MembresState { + const MembreUpdated(this.membre); + + final MembreModel membre; + + @override + List get props => [membre]; +} + +/// État de succès pour la suppression d'un membre +class MembreDeleted extends MembresState { + const MembreDeleted(this.membreId); + + final String membreId; + + @override + List get props => [membreId]; +} + +/// État d'erreur +class MembresError extends MembresState { + const MembresError({ + required this.failure, + this.previousState, + }); + + final Failure failure; + final MembresState? previousState; + + @override + List get props => [failure, previousState]; + + /// Message d'erreur formaté + String get message => failure.message; + + /// Code d'erreur + String? get code => failure.code; + + /// Indique si c'est une erreur réseau + bool get isNetworkError => failure is NetworkFailure; + + /// Indique si c'est une erreur serveur + bool get isServerError => failure is ServerFailure; + + /// Indique si c'est une erreur d'authentification + bool get isAuthError => failure is AuthFailure; + + /// Indique si c'est une erreur de validation + bool get isValidationError => failure is ValidationFailure; +} + +/// État d'erreur avec données existantes (pour les erreurs non critiques) +class MembresErrorWithData extends MembresState { + const MembresErrorWithData({ + required this.failure, + required this.membres, + this.isSearchResult = false, + this.searchQuery, + }); + + final Failure failure; + final List membres; + final bool isSearchResult; + final String? searchQuery; + + @override + List get props => [failure, membres, isSearchResult, searchQuery]; + + /// Message d'erreur formaté + String get message => failure.message; +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_list_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_list_page.dart new file mode 100644 index 0000000..f18999c --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_list_page.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/widgets/coming_soon_page.dart'; +import '../bloc/membres_bloc.dart'; +import '../bloc/membres_event.dart'; +import '../bloc/membres_state.dart'; +import '../widgets/membre_card.dart'; +import '../widgets/membres_search_bar.dart'; + + +/// Page de liste des membres avec fonctionnalités avancées +class MembresListPage extends StatefulWidget { + const MembresListPage({super.key}); + + @override + State createState() => _MembresListPageState(); +} + +class _MembresListPageState extends State { + final RefreshController _refreshController = RefreshController(); + final TextEditingController _searchController = TextEditingController(); + late MembresBloc _membresBloc; + + @override + void initState() { + super.initState(); + _membresBloc = getIt(); + _membresBloc.add(const LoadMembres()); + } + + @override + void dispose() { + _refreshController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _membresBloc, + child: Scaffold( + backgroundColor: AppTheme.backgroundLight, + appBar: AppBar( + title: const Text( + 'Membres', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 20, + ), + ), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: () => _showAddMemberDialog(), + tooltip: 'Ajouter un membre', + ), + IconButton( + icon: const Icon(Icons.analytics_outlined), + onPressed: () => _showStatsDialog(), + tooltip: 'Statistiques', + ), + ], + ), + body: Column( + children: [ + // Barre de recherche + Container( + color: AppTheme.primaryColor, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: MembresSearchBar( + controller: _searchController, + onSearch: (query) { + _membresBloc.add(SearchMembres(query)); + }, + onClear: () { + _searchController.clear(); + _membresBloc.add(const LoadMembres()); + }, + ), + ), + ), + + // Liste des membres + Expanded( + child: BlocConsumer( + listener: (context, state) { + if (state is MembresError) { + _showErrorSnackBar(state.message); + } else if (state is MembresErrorWithData) { + _showErrorSnackBar(state.message); + } + + // Arrêter le refresh + if (state is! MembresRefreshing && state is! MembresLoading) { + _refreshController.refreshCompleted(); + } + }, + builder: (context, state) { + if (state is MembresLoading) { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppTheme.primaryColor), + ), + ); + } + + if (state is MembresError) { + return _buildErrorWidget(state); + } + + if (state is MembresLoaded || state is MembresErrorWithData) { + final membres = state is MembresLoaded + ? state.membres + : (state as MembresErrorWithData).membres; + + final isSearchResult = state is MembresLoaded + ? state.isSearchResult + : (state as MembresErrorWithData).isSearchResult; + + return SmartRefresher( + controller: _refreshController, + onRefresh: () => _membresBloc.add(const RefreshMembres()), + header: const WaterDropHeader( + waterDropColor: AppTheme.primaryColor, + ), + child: membres.isEmpty + ? _buildEmptyWidget(isSearchResult) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: membres.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MembreCard( + membre: membres[index], + onTap: () => _showMemberDetails(membres[index]), + onEdit: () => _showEditMemberDialog(membres[index]), + onDelete: () => _showDeleteConfirmation(membres[index]), + ), + ); + }, + ), + ); + } + + return const Center( + child: Text( + 'Aucune donnée disponible', + style: TextStyle( + fontSize: 16, + color: AppTheme.textSecondary, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + /// Widget d'erreur avec bouton de retry + Widget _buildErrorWidget(MembresError state) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + state.isNetworkError ? Icons.wifi_off : Icons.error_outline, + size: 64, + color: AppTheme.errorColor, + ), + const SizedBox(height: 16), + Text( + state.isNetworkError + ? 'Problème de connexion' + : 'Une erreur est survenue', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + 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), + ElevatedButton.icon( + onPressed: () => _membresBloc.add(const LoadMembres()), + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } + + /// Widget vide (aucun membre trouvé) + Widget _buildEmptyWidget(bool isSearchResult) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isSearchResult ? Icons.search_off : Icons.people_outline, + size: 64, + color: AppTheme.textHint, + ), + const SizedBox(height: 16), + Text( + isSearchResult + ? 'Aucun membre trouvé' + : 'Aucun membre enregistré', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + isSearchResult + ? 'Essayez avec d\'autres termes de recherche' + : 'Commencez par ajouter votre premier membre', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + if (!isSearchResult) ...[ + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _showAddMemberDialog, + icon: const Icon(Icons.add), + label: const Text('Ajouter un membre'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ], + ), + ), + ); + } + + /// Affiche une snackbar d'erreur + void _showErrorSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: AppTheme.errorColor, + action: SnackBarAction( + label: 'Fermer', + textColor: Colors.white, + onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(), + ), + ), + ); + } + + /// Affiche les détails d'un membre + void _showMemberDetails(membre) { + // TODO: Implémenter la page de détails + showDialog( + context: context, + builder: (context) => const ComingSoonPage( + title: 'Détails du membre', + description: 'La page de détails du membre sera bientôt disponible.', + icon: Icons.person, + color: AppTheme.primaryColor, + ), + ); + } + + /// Affiche le dialog d'ajout de membre + void _showAddMemberDialog() { + // TODO: Implémenter le formulaire d'ajout + showDialog( + context: context, + builder: (context) => const ComingSoonPage( + title: 'Ajouter un membre', + description: 'Le formulaire d\'ajout de membre sera bientôt disponible.', + icon: Icons.person_add, + color: AppTheme.successColor, + ), + ); + } + + /// Affiche le dialog d'édition de membre + void _showEditMemberDialog(membre) { + // TODO: Implémenter le formulaire d'édition + showDialog( + context: context, + builder: (context) => const ComingSoonPage( + title: 'Modifier le membre', + description: 'Le formulaire de modification sera bientôt disponible.', + icon: Icons.edit, + color: AppTheme.warningColor, + ), + ); + } + + /// Affiche la confirmation de suppression + void _showDeleteConfirmation(membre) { + // TODO: Implémenter la confirmation de suppression + showDialog( + context: context, + builder: (context) => const ComingSoonPage( + title: 'Supprimer le membre', + description: 'La confirmation de suppression sera bientôt disponible.', + icon: Icons.delete, + color: AppTheme.errorColor, + ), + ); + } + + /// Affiche les statistiques + void _showStatsDialog() { + // TODO: Implémenter les statistiques + showDialog( + context: context, + builder: (context) => const ComingSoonPage( + title: 'Statistiques', + description: 'Les statistiques des membres seront bientôt disponibles.', + icon: Icons.analytics, + color: AppTheme.infoColor, + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_card.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_card.dart new file mode 100644 index 0000000..a44baff --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_card.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Card pour afficher un membre dans la liste +class MembreCard extends StatelessWidget { + const MembreCard({ + super.key, + required this.membre, + this.onTap, + this.onEdit, + this.onDelete, + }); + + final MembreModel membre; + final VoidCallback? onTap; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header avec avatar et actions + Row( + children: [ + // Avatar + CircleAvatar( + radius: 24, + backgroundColor: AppTheme.primaryColor, + child: Text( + membre.initiales, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ), + + const SizedBox(width: 12), + + // Informations principales + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + membre.nomComplet, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + membre.numeroMembre, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + + // Badge de statut + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: _getStatusColor(membre.statut).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getStatusColor(membre.statut), + width: 1, + ), + ), + child: Text( + _getStatusLabel(membre.statut), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: _getStatusColor(membre.statut), + ), + ), + ), + + // Menu d'actions + PopupMenuButton( + icon: const Icon( + Icons.more_vert, + color: AppTheme.textSecondary, + ), + onSelected: (value) { + switch (value) { + case 'edit': + onEdit?.call(); + break; + case 'delete': + onDelete?.call(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit, size: 16), + SizedBox(width: 8), + Text('Modifier'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, size: 16, color: AppTheme.errorColor), + SizedBox(width: 8), + Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), + ], + ), + ), + ], + ), + ], + ), + + const SizedBox(height: 12), + + // Informations de contact + Row( + children: [ + Expanded( + child: _buildInfoItem( + icon: Icons.email_outlined, + text: membre.email, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildInfoItem( + icon: Icons.phone_outlined, + text: membre.telephone, + ), + ), + ], + ), + + // Adresse si disponible + if (membre.adresseComplete.isNotEmpty) ...[ + const SizedBox(height: 8), + _buildInfoItem( + icon: Icons.location_on_outlined, + text: membre.adresseComplete, + ), + ], + + // Profession si disponible + if (membre.profession?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + _buildInfoItem( + icon: Icons.work_outline, + text: membre.profession!, + ), + ], + + const SizedBox(height: 8), + + // Footer avec date d'adhésion + Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 14, + color: AppTheme.textHint, + ), + const SizedBox(width: 4), + Text( + 'Membre depuis ${_formatDate(membre.dateAdhesion)}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textHint, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// Widget pour afficher une information avec icône + Widget _buildInfoItem({ + required IconData icon, + required String text, + }) { + return Row( + children: [ + Icon( + icon, + size: 14, + color: AppTheme.textSecondary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + /// Retourne la couleur associée au statut + Color _getStatusColor(String statut) { + switch (statut.toUpperCase()) { + case 'ACTIF': + return AppTheme.successColor; + case 'INACTIF': + return AppTheme.warningColor; + case 'SUSPENDU': + return AppTheme.errorColor; + default: + return AppTheme.textSecondary; + } + } + + /// Retourne le label du statut + String _getStatusLabel(String statut) { + switch (statut.toUpperCase()) { + case 'ACTIF': + return 'ACTIF'; + case 'INACTIF': + return 'INACTIF'; + case 'SUSPENDU': + return 'SUSPENDU'; + default: + return statut.toUpperCase(); + } + } + + /// Formate une date pour l'affichage + String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays < 30) { + return '${difference.inDays} jours'; + } else if (difference.inDays < 365) { + final months = (difference.inDays / 30).floor(); + return '$months mois'; + } else { + final years = (difference.inDays / 365).floor(); + return '$years an${years > 1 ? 's' : ''}'; + } + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_search_bar.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_search_bar.dart new file mode 100644 index 0000000..2f129b3 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_search_bar.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Barre de recherche pour les membres +class MembresSearchBar extends StatefulWidget { + const MembresSearchBar({ + super.key, + required this.controller, + required this.onSearch, + required this.onClear, + this.hintText = 'Rechercher un membre...', + }); + + final TextEditingController controller; + final ValueChanged onSearch; + final VoidCallback onClear; + final String hintText; + + @override + State createState() => _MembresSearchBarState(); +} + +class _MembresSearchBarState extends State { + bool _isSearching = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onTextChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onTextChanged); + super.dispose(); + } + + void _onTextChanged() { + final hasText = widget.controller.text.isNotEmpty; + if (_isSearching != hasText) { + setState(() { + _isSearching = hasText; + }); + } + } + + void _onSubmitted(String value) { + if (value.trim().isNotEmpty) { + widget.onSearch(value.trim()); + } else { + widget.onClear(); + } + } + + void _onClearPressed() { + widget.controller.clear(); + widget.onClear(); + FocusScope.of(context).unfocus(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: widget.controller, + onSubmitted: _onSubmitted, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: const TextStyle( + color: AppTheme.textHint, + fontSize: 16, + ), + prefixIcon: const Icon( + Icons.search, + color: AppTheme.textSecondary, + ), + suffixIcon: _isSearching + ? IconButton( + icon: const Icon( + Icons.clear, + color: AppTheme.textSecondary, + ), + onPressed: _onClearPressed, + tooltip: 'Effacer la recherche', + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: AppTheme.primaryColor, + width: 2, + ), + ), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + style: const TextStyle( + fontSize: 16, + color: AppTheme.textPrimary, + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_stats_card.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_stats_card.dart new file mode 100644 index 0000000..03f2ae5 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_stats_card.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Card pour afficher les statistiques des membres +class MembresStatsCard extends StatelessWidget { + const MembresStatsCard({ + super.key, + required this.stats, + }); + + final Map stats; + + @override + Widget build(BuildContext context) { + final nombreMembresActifs = stats['nombreMembresActifs'] as int? ?? 0; + final nombreMembresInactifs = stats['nombreMembresInactifs'] as int? ?? 0; + final nombreMembresSuspendus = stats['nombreMembresSuspendus'] as int? ?? 0; + final total = nombreMembresActifs + nombreMembresInactifs + nombreMembresSuspendus; + + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.analytics, + color: AppTheme.primaryColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Statistiques des membres', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + ], + ), + + const SizedBox(height: 20), + + // Statistiques principales + Row( + children: [ + Expanded( + child: _buildStatItem( + title: 'Total', + value: total.toString(), + color: AppTheme.primaryColor, + icon: Icons.people, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildStatItem( + title: 'Actifs', + value: nombreMembresActifs.toString(), + color: AppTheme.successColor, + icon: Icons.check_circle, + ), + ), + ], + ), + + const SizedBox(height: 12), + + Row( + children: [ + Expanded( + child: _buildStatItem( + title: 'Inactifs', + value: nombreMembresInactifs.toString(), + color: AppTheme.warningColor, + icon: Icons.pause_circle, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildStatItem( + title: 'Suspendus', + value: nombreMembresSuspendus.toString(), + color: AppTheme.errorColor, + icon: Icons.block, + ), + ), + ], + ), + + if (total > 0) ...[ + const SizedBox(height: 24), + + // Graphique en secteurs + SizedBox( + height: 200, + child: PieChart( + PieChartData( + sections: [ + if (nombreMembresActifs > 0) + PieChartSectionData( + value: nombreMembresActifs.toDouble(), + title: '${(nombreMembresActifs / total * 100).round()}%', + color: AppTheme.successColor, + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + if (nombreMembresInactifs > 0) + PieChartSectionData( + value: nombreMembresInactifs.toDouble(), + title: '${(nombreMembresInactifs / total * 100).round()}%', + color: AppTheme.warningColor, + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + if (nombreMembresSuspendus > 0) + PieChartSectionData( + value: nombreMembresSuspendus.toDouble(), + title: '${(nombreMembresSuspendus / total * 100).round()}%', + color: AppTheme.errorColor, + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + centerSpaceRadius: 40, + sectionsSpace: 2, + ), + ), + ), + + const SizedBox(height: 16), + + // Légende + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (nombreMembresActifs > 0) + _buildLegendItem('Actifs', AppTheme.successColor), + if (nombreMembresInactifs > 0) + _buildLegendItem('Inactifs', AppTheme.warningColor), + if (nombreMembresSuspendus > 0) + _buildLegendItem('Suspendus', AppTheme.errorColor), + ], + ), + ], + ], + ), + ), + ); + } + + /// Widget pour une statistique individuelle + Widget _buildStatItem({ + required String title, + required String value, + required Color color, + required IconData icon, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withOpacity(0.3), + width: 1, + ), + ), + child: Column( + children: [ + Icon( + icon, + color: color, + size: 24, + ), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: color, + ), + ), + ], + ), + ); + } + + /// Widget pour un élément de légende + Widget _buildLegendItem(String label, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/navigation/presentation/pages/main_navigation.dart b/unionflow-mobile-apps/lib/features/navigation/presentation/pages/main_navigation.dart index 9745fbf..6c046d4 100644 --- a/unionflow-mobile-apps/lib/features/navigation/presentation/pages/main_navigation.dart +++ b/unionflow-mobile-apps/lib/features/navigation/presentation/pages/main_navigation.dart @@ -4,7 +4,7 @@ import '../../../../shared/theme/app_theme.dart'; import '../../../../shared/widgets/coming_soon_page.dart'; import '../../../../shared/widgets/buttons/buttons.dart'; import '../../../dashboard/presentation/pages/enhanced_dashboard.dart'; -import '../../../members/presentation/pages/members_list_page.dart'; +import '../../../members/presentation/pages/membres_list_page.dart'; import '../widgets/custom_bottom_nav_bar.dart'; class MainNavigation extends StatefulWidget { @@ -17,14 +17,12 @@ class MainNavigation extends StatefulWidget { class _MainNavigationState extends State with TickerProviderStateMixin { int _currentIndex = 0; - late PageController _pageController; late AnimationController _fabAnimationController; late Animation _fabAnimation; @override void initState() { super.initState(); - _pageController = PageController(); _fabAnimationController = AnimationController( duration: const Duration(milliseconds: 300), @@ -44,7 +42,6 @@ class _MainNavigationState extends State @override void dispose() { - _pageController.dispose(); _fabAnimationController.dispose(); super.dispose(); } @@ -85,9 +82,8 @@ class _MainNavigationState extends State @override Widget build(BuildContext context) { return Scaffold( - body: PageView( - controller: _pageController, - onPageChanged: _onPageChanged, + body: IndexedStack( + index: _currentIndex, children: [ EnhancedDashboard( onNavigateToTab: _onTabTapped, @@ -151,33 +147,23 @@ class _MainNavigationState extends State } } - void _onPageChanged(int index) { - setState(() { - _currentIndex = index; - }); - - // Animation du FAB - if (index == 1 || index == 2 || index == 3) { - _fabAnimationController.forward(); - } else { - _fabAnimationController.reverse(); - } - - // Vibration légère - HapticFeedback.selectionClick(); - } + void _onTabTapped(int index) { if (_currentIndex != index) { setState(() { _currentIndex = index; }); - - _pageController.animateToPage( - index, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + + // Animation du FAB + if (index == 1 || index == 2 || index == 3) { + _fabAnimationController.forward(); + } else { + _fabAnimationController.reverse(); + } + + // Vibration légère + HapticFeedback.selectionClick(); } } @@ -219,11 +205,11 @@ class _MainNavigationState extends State } Widget _buildMembresPage() { - return MembersListPage(); + return const MembresListPage(); } Widget _buildCotisationsPage() { - return ComingSoonPage( + return const ComingSoonPage( title: 'Module Cotisations', description: 'Suivi et gestion des cotisations avec paiements automatiques', icon: Icons.payment_rounded, @@ -240,7 +226,7 @@ class _MainNavigationState extends State } Widget _buildEventsPage() { - return ComingSoonPage( + return const ComingSoonPage( title: 'Module Événements', description: 'Organisation et gestion d\'événements avec calendrier intégré', icon: Icons.event_rounded, @@ -257,51 +243,75 @@ class _MainNavigationState extends State } Widget _buildMorePage() { - return Scaffold( - backgroundColor: AppTheme.backgroundLight, - appBar: AppBar( - title: const Text('Plus'), - backgroundColor: AppTheme.infoColor, - elevation: 0, - automaticallyImplyLeading: false, - actions: [ - IconButton( - icon: const Icon(Icons.settings), - onPressed: () {}, - ), - ], - ), - body: ListView( - padding: const EdgeInsets.all(16), + return Container( + color: AppTheme.backgroundLight, + child: Column( children: [ - _buildMoreSection( - 'Gestion', - [ - _buildMoreItem(Icons.analytics, 'Rapports', 'Génération de rapports'), - _buildMoreItem(Icons.account_balance, 'Finances', 'Tableau de bord financier'), - _buildMoreItem(Icons.message, 'Communications', 'Messages et notifications'), - _buildMoreItem(Icons.folder, 'Documents', 'Gestion documentaire'), - ], + // Header personnalisé au lieu d'AppBar + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 50, 16, 16), + decoration: const BoxDecoration( + color: AppTheme.infoColor, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Plus', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + IconButton( + icon: const Icon(Icons.settings, color: Colors.white), + onPressed: () {}, + ), + ], + ), ), - const SizedBox(height: 24), - _buildMoreSection( - 'Paramètres', - [ - _buildMoreItem(Icons.person, 'Mon profil', 'Informations personnelles'), - _buildMoreItem(Icons.notifications, 'Notifications', 'Préférences de notification'), - _buildMoreItem(Icons.security, 'Sécurité', 'Mot de passe et sécurité'), - _buildMoreItem(Icons.language, 'Langue', 'Changer la langue'), - ], - ), - const SizedBox(height: 24), - _buildMoreSection( - 'Support', - [ - _buildMoreItem(Icons.help, 'Aide', 'Centre d\'aide et FAQ'), - _buildMoreItem(Icons.contact_support, 'Contact', 'Nous contacter'), - _buildMoreItem(Icons.info, 'À propos', 'Informations sur l\'application'), - _buildMoreItem(Icons.logout, 'Déconnexion', 'Se déconnecter', isDestructive: true), - ], + // Contenu scrollable + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildMoreSection( + 'Gestion', + [ + _buildMoreItem(Icons.analytics, 'Rapports', 'Génération de rapports'), + _buildMoreItem(Icons.account_balance, 'Finances', 'Tableau de bord financier'), + _buildMoreItem(Icons.message, 'Communications', 'Messages et notifications'), + _buildMoreItem(Icons.folder, 'Documents', 'Gestion documentaire'), + ], + ), + const SizedBox(height: 24), + _buildMoreSection( + 'Paramètres', + [ + _buildMoreItem(Icons.person, 'Mon profil', 'Informations personnelles'), + _buildMoreItem(Icons.notifications, 'Notifications', 'Préférences de notification'), + _buildMoreItem(Icons.security, 'Sécurité', 'Mot de passe et sécurité'), + _buildMoreItem(Icons.language, 'Langue', 'Changer la langue'), + ], + ), + const SizedBox(height: 24), + _buildMoreSection( + 'Support', + [ + _buildMoreItem(Icons.help, 'Aide', 'Centre d\'aide et FAQ'), + _buildMoreItem(Icons.contact_support, 'Contact', 'Nous contacter'), + _buildMoreItem(Icons.info, 'À propos', 'Informations sur l\'application'), + _buildMoreItem(Icons.logout, 'Déconnexion', 'Se déconnecter', isDestructive: true), + ], + ), + ], + ), ), ], ), diff --git a/unionflow-mobile-apps/lib/main.dart b/unionflow-mobile-apps/lib/main.dart index 1e80448..afc7c77 100644 --- a/unionflow-mobile-apps/lib/main.dart +++ b/unionflow-mobile-apps/lib/main.dart @@ -5,15 +5,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'core/auth/bloc/temp_auth_bloc.dart'; import 'core/auth/bloc/auth_event.dart'; import 'core/auth/services/temp_auth_service.dart'; +import 'core/di/injection.dart'; import 'shared/theme/app_theme.dart'; import 'app_temp.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - + + // Configuration de l'injection de dépendances + await configureDependencies(); + // Configuration du système await _configureApp(); - + // Lancement de l'application runApp(const UnionFlowTempApp()); } diff --git a/unionflow-mobile-apps/lib/main_ultra_simple.dart b/unionflow-mobile-apps/lib/main_ultra_simple.dart index faee509..8352c62 100644 --- a/unionflow-mobile-apps/lib/main_ultra_simple.dart +++ b/unionflow-mobile-apps/lib/main_ultra_simple.dart @@ -4,16 +4,21 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'core/auth/bloc/temp_auth_bloc.dart'; import 'core/auth/bloc/auth_event.dart'; -import 'core/auth/services/ultra_simple_auth_service.dart'; +import 'core/auth/services/temp_auth_service.dart'; +import 'core/di/injection.dart'; + import 'shared/theme/app_theme.dart'; import 'app_ultra_simple.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - + + // Configuration de l'injection de dépendances + await configureDependencies(); + // Configuration du système await _configureApp(); - + // Lancement de l'application runApp(const UnionFlowUltraSimpleApp()); } @@ -39,7 +44,7 @@ Future _configureApp() async { /// Classe BLoC ultra-simple qui utilise UltraSimpleAuthService class UltraSimpleAuthBloc extends TempAuthBloc { - UltraSimpleAuthBloc(UltraSimpleAuthService authService) : super(authService); + UltraSimpleAuthBloc(TempAuthService authService) : super(authService); } /// Application principale ultra-simple @@ -50,7 +55,7 @@ class UnionFlowUltraSimpleApp extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) { - final authService = UltraSimpleAuthService(); + final authService = TempAuthService(); final authBloc = UltraSimpleAuthBloc(authService); authBloc.add(const AuthInitializeRequested()); return authBloc; diff --git a/unionflow-mobile-apps/pubspec.lock b/unionflow-mobile-apps/pubspec.lock index faac324..4e19471 100644 --- a/unionflow-mobile-apps/pubspec.lock +++ b/unionflow-mobile-apps/pubspec.lock @@ -118,6 +118,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.11.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -267,6 +291,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.6" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -275,6 +307,54 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -317,6 +397,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" http_multi_server: dependency: transitive description: @@ -374,13 +462,21 @@ packages: source: hosted version: "0.6.7" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c + url: "https://pub.dev" + source: hosted + version: "6.9.0" jwt_decoder: dependency: "direct main" description: @@ -469,6 +565,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" nested: dependency: transitive description: @@ -477,6 +581,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -485,6 +597,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -493,6 +621,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" + source: hosted + version: "2.2.15" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -573,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + pull_to_refresh: + dependency: "direct main" + description: + name: pull_to_refresh + sha256: bbadd5a931837b57739cf08736bea63167e284e71fb23b218c8c9a6e042aad12 + url: "https://pub.dev" + source: hosted + version: "2.0.0" recase: dependency: transitive description: @@ -581,6 +741,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -653,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -666,6 +842,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" source_span: dependency: transitive description: @@ -674,6 +858,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -706,6 +938,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" term_glyph: dependency: transitive description: @@ -738,6 +978,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: @@ -786,6 +1098,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" xdg_directories: dependency: transitive description: diff --git a/unionflow-mobile-apps/pubspec.yaml b/unionflow-mobile-apps/pubspec.yaml index db8f6f7..5422edb 100644 --- a/unionflow-mobile-apps/pubspec.yaml +++ b/unionflow-mobile-apps/pubspec.yaml @@ -18,26 +18,41 @@ dependencies: dio: ^5.7.0 fl_chart: ^0.66.2 intl: ^0.19.0 - + # Authentication (versions compatibles) - # flutter_secure_storage: ^9.2.2 # Temporairement désactivé pour Android + flutter_secure_storage: ^9.2.2 jwt_decoder: ^2.0.1 crypto: ^3.0.5 shared_preferences: ^2.3.2 - + # HTTP pretty_dio_logger: ^1.4.0 - + # DI (versions stables) get_it: ^7.7.0 injectable: ^2.4.4 + # JSON serialization + json_annotation: ^4.9.0 + + # UI Components + cached_network_image: ^3.4.1 + shimmer: ^3.0.0 + pull_to_refresh: ^2.0.0 + + # Utils + uuid: ^4.5.1 + url_launcher: ^6.3.1 + package_info_plus: ^8.0.2 + dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 injectable_generator: ^2.6.2 build_runner: ^2.4.13 + json_serializable: ^6.8.0 + mockito: ^5.4.4 flutter: uses-material-design: true \ No newline at end of file diff --git a/unionflow-server-api/src/test/java/dev/lions/unionflow/server/api/dto/formuleabonnement/FormuleAbonnementDTOBasicTest.java b/unionflow-server-api/src/test/java/dev/lions/unionflow/server/api/dto/formuleabonnement/FormuleAbonnementDTOBasicTest.java index f241247..9e63248 100644 --- a/unionflow-server-api/src/test/java/dev/lions/unionflow/server/api/dto/formuleabonnement/FormuleAbonnementDTOBasicTest.java +++ b/unionflow-server-api/src/test/java/dev/lions/unionflow/server/api/dto/formuleabonnement/FormuleAbonnementDTOBasicTest.java @@ -432,6 +432,31 @@ class FormuleAbonnementDTOBasicTest { formule.setPersonnalisationInterface(false); // Score = (10 + 15) * 100 / 100 = 25 assertThat(formule.getScoreFonctionnalites()).isEqualTo(25); + + // Test spécifique pour couvrir toutes les branches de l'expression ternaire + // Cas où score = 0 mais total > 0 (toutes fonctionnalités false) + formule.setSupportTechnique(false); + formule.setSauvegardeAutomatique(false); + formule.setFonctionnalitesAvancees(false); + formule.setApiAccess(false); + formule.setRapportsPersonnalises(false); + formule.setIntegrationsTierces(false); + formule.setMultiLangues(false); + formule.setPersonnalisationInterface(false); + // total = 100, score = 0, donc (0 * 100) / 100 = 0 + assertThat(formule.getScoreFonctionnalites()).isEqualTo(0); + + // Test avec un seul élément activé pour vérifier la division + formule.setSupportTechnique(true); // score = 10, total = 100 + formule.setSauvegardeAutomatique(false); + formule.setFonctionnalitesAvancees(false); + formule.setApiAccess(false); + formule.setRapportsPersonnalises(false); + formule.setIntegrationsTierces(false); + formule.setMultiLangues(false); + formule.setPersonnalisationInterface(false); + // Score = (10 * 100) / 100 = 10 + assertThat(formule.getScoreFonctionnalites()).isEqualTo(10); } @Test diff --git a/unionflow-server-impl-quarkus/docker-compose.dev.yml b/unionflow-server-impl-quarkus/docker-compose.dev.yml new file mode 100644 index 0000000..5b3c185 --- /dev/null +++ b/unionflow-server-impl-quarkus/docker-compose.dev.yml @@ -0,0 +1,43 @@ +version: '3.8' + +services: + postgres-dev: + image: postgres:15-alpine + container_name: unionflow-postgres-dev + environment: + POSTGRES_DB: unionflow_dev + POSTGRES_USER: unionflow_dev + POSTGRES_PASSWORD: dev123 + POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C" + ports: + - "5432:5432" + volumes: + - postgres_dev_data:/var/lib/postgresql/data + - ./src/main/resources/db/init:/docker-entrypoint-initdb.d + networks: + - unionflow-dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U unionflow_dev -d unionflow_dev"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + adminer: + image: adminer:4.8.1 + container_name: unionflow-adminer + ports: + - "8081:8080" + networks: + - unionflow-dev + depends_on: + - postgres-dev + restart: unless-stopped + +volumes: + postgres_dev_data: + driver: local + +networks: + unionflow-dev: + driver: bridge diff --git a/unionflow-server-impl-quarkus/src/main/resources/application.yml b/unionflow-server-impl-quarkus/src/main/resources/application.yml index c689f6f..6542a82 100644 --- a/unionflow-server-impl-quarkus/src/main/resources/application.yml +++ b/unionflow-server-impl-quarkus/src/main/resources/application.yml @@ -80,15 +80,18 @@ quarkus: "%dev": quarkus: datasource: - username: unionflow_dev - password: dev123 + db-kind: h2 + username: sa + password: "" jdbc: - url: jdbc:postgresql://localhost:5432/unionflow_dev + url: jdbc:h2:mem:unionflow_dev;DB_CLOSE_DELAY=-1;MODE=PostgreSQL hibernate-orm: database: generation: drop-and-create log: sql: true + flyway: + migrate-at-start: false log: category: "dev.lions.unionflow": DEBUG