Appli Flutter se connecte bien à l'API.

This commit is contained in:
DahoudG
2025-09-12 03:15:21 +00:00
parent 8184bc77bb
commit 3df010add7
33 changed files with 3124 additions and 339 deletions

View File

@@ -2,8 +2,8 @@
> Application mobile moderne pour la gestion d'associations en Côte d'Ivoire avec intégration Wave Money > 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/) [![Flutter](https://img.shields.io/badge/Flutter-3.5.3-blue.svg)](https://flutter.dev/)
[![TypeScript](https://img.shields.io/badge/TypeScript-4.8.4-blue.svg)](https://www.typescriptlang.org/) [![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/) [![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/) [![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** ### 🎨 **Interface Ultra Moderne**
- **Design System** cohérent inspiré des couleurs ivoiriennes - **Design System** cohérent inspiré des couleurs ivoiriennes
- **Animations fluides** avec Reanimated 3 - **Animations fluides** avec Flutter Animations
- **Mode sombre** automatique - **Mode sombre** automatique
- **Responsive design** pour tous les écrans - **Responsive design** pour tous les écrans
- **Accessibilité** complète (WCAG 2.1) - **Accessibilité** complète (WCAG 2.1)
@@ -38,15 +38,37 @@
- **Synchronisation temps réel** avec le backend - **Synchronisation temps réel** avec le backend
- **Cache intelligent** pour performance optimale - **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 <repository-url>
cd unionflow-mobile-apps
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) # Installer les dépendances
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) flutter pub get
For help getting started with Flutter development, view the # Générer les fichiers de code (DI)
[online documentation](https://docs.flutter.dev/), which offers tutorials, flutter packages pub run build_runner build
samples, guidance on mobile development, and a full API reference.
# 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

View File

@@ -3,7 +3,7 @@ import 'package:injectable/injectable.dart';
import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/auth_state.dart'; import '../models/auth_state.dart';
import '../models/login_request.dart'; import '../models/login_request.dart';
import '../models/login_response.dart';
import '../models/user_info.dart'; import '../models/user_info.dart';
import '../storage/secure_token_storage.dart'; import '../storage/secure_token_storage.dart';
import 'auth_api_service.dart'; import 'auth_api_service.dart';

View File

@@ -1,5 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/auth_state.dart'; import '../models/auth_state.dart';
import '../models/login_request.dart'; import '../models/login_request.dart';
import '../models/user_info.dart'; import '../models/user_info.dart';

View File

@@ -1,5 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/auth_state.dart'; import '../models/auth_state.dart';
import '../models/login_request.dart'; import '../models/login_request.dart';
import '../models/user_info.dart'; import '../models/user_info.dart';

View File

@@ -83,7 +83,8 @@ class SecureTokenStorage {
/// Récupère la date d'expiration du refresh token /// Récupère la date d'expiration du refresh token
Future<DateTime?> getRefreshTokenExpirationDate() async { Future<DateTime?> getRefreshTokenExpirationDate() async {
try { try {
final expiresAtString = await _storage.read(key: _refreshExpiresAtKey); final prefs = await _prefs;
final expiresAtString = prefs.getString(_refreshExpiresAtKey);
if (expiresAtString == null) return null; if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString); return DateTime.parse(expiresAtString);
@@ -133,9 +134,10 @@ class SecureTokenStorage {
/// Met à jour le token d'accès /// Met à jour le token d'accès
Future<void> updateAccessToken(String accessToken, DateTime expiresAt) async { Future<void> updateAccessToken(String accessToken, DateTime expiresAt) async {
try { try {
final prefs = await _prefs;
await Future.wait([ await Future.wait([
_storage.write(key: _accessTokenKey, value: accessToken), prefs.setString(_accessTokenKey, accessToken),
_storage.write(key: _expiresAtKey, value: expiresAt.toIso8601String()), prefs.setString(_expiresAtKey, expiresAt.toIso8601String()),
]); ]);
} catch (e) { } catch (e) {
throw StorageException('Erreur lors de la mise à jour du token d\'accès: $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 /// Vérifie si les données d'authentification existent
Future<bool> hasAuthData() async { Future<bool> hasAuthData() async {
try { try {
final accessToken = await _storage.read(key: _accessTokenKey); final prefs = await _prefs;
final refreshToken = await _storage.read(key: _refreshTokenKey); final accessToken = prefs.getString(_accessTokenKey);
final refreshToken = prefs.getString(_refreshTokenKey);
return accessToken != null && refreshToken != null; return accessToken != null && refreshToken != null;
} catch (e) { } catch (e) {
return false; return false;
@@ -184,12 +187,13 @@ class SecureTokenStorage {
/// Efface toutes les données d'authentification /// Efface toutes les données d'authentification
Future<void> clearAuthData() async { Future<void> clearAuthData() async {
try { try {
final prefs = await _prefs;
await Future.wait([ await Future.wait([
_storage.delete(key: _accessTokenKey), prefs.remove(_accessTokenKey),
_storage.delete(key: _refreshTokenKey), prefs.remove(_refreshTokenKey),
_storage.delete(key: _userInfoKey), prefs.remove(_userInfoKey),
_storage.delete(key: _expiresAtKey), prefs.remove(_expiresAtKey),
_storage.delete(key: _refreshExpiresAtKey), prefs.remove(_refreshExpiresAtKey),
]); ]);
} catch (e) { } catch (e) {
throw StorageException('Erreur lors de l\'effacement des données d\'authentification: $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 /// Active/désactive l'authentification biométrique
Future<void> setBiometricEnabled(bool enabled) async { Future<void> setBiometricEnabled(bool enabled) async {
try { try {
await _storage.write(key: _biometricEnabledKey, value: enabled.toString()); final prefs = await _prefs;
await prefs.setBool(_biometricEnabledKey, enabled);
} catch (e) { } catch (e) {
throw StorageException('Erreur lors de la configuration biométrique: $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 /// Vérifie si l'authentification biométrique est activée
Future<bool> isBiometricEnabled() async { Future<bool> isBiometricEnabled() async {
try { try {
final enabled = await _storage.read(key: _biometricEnabledKey); final prefs = await _prefs;
return enabled == 'true'; return prefs.getBool(_biometricEnabledKey) ?? false;
} catch (e) { } catch (e) {
return false; return false;
} }
@@ -218,7 +223,8 @@ class SecureTokenStorage {
/// Efface toutes les données stockées /// Efface toutes les données stockées
Future<void> clearAll() async { Future<void> clearAll() async {
try { try {
await _storage.deleteAll(); final prefs = await _prefs;
await prefs.clear();
} catch (e) { } catch (e) {
throw StorageException('Erreur lors de l\'effacement de toutes les données: $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 /// Vérifie si le stockage sécurisé est disponible
Future<bool> isAvailable() async { Future<bool> isAvailable() async {
try { try {
await _storage.containsKey(key: 'test'); final prefs = await _prefs;
return true; return prefs.containsKey('test');
} catch (e) { } catch (e) {
return false; return false;
} }

View File

@@ -1,7 +1,7 @@
class AppConstants { class AppConstants {
// API Configuration // API Configuration
static const String baseUrl = 'http://localhost:8099'; // Backend UnionFlow static const String baseUrl = 'http://192.168.1.13:8080'; // Backend UnionFlow
static const String apiVersion = '/api/v1'; static const String apiVersion = '/api';
// Timeout // Timeout
static const Duration connectTimeout = Duration(seconds: 30); static const Duration connectTimeout = Duration(seconds: 30);

View File

@@ -4,42 +4,60 @@
// InjectableConfigGenerator // InjectableConfigGenerator
// ************************************************************************** // **************************************************************************
// ignore_for_file: unnecessary_lambdas // ignore_for_file: type=lint
// ignore_for_file: lines_longer_than_80_chars
// coverage:ignore-file // coverage:ignore-file
import 'package:get_it/get_it.dart' as _i1; // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:injectable/injectable.dart' as _i2; 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; extension GetItInjectableX on _i174.GetIt {
import '../auth/services/auth_api_service.dart' as _i4; // initializes the registration of main-scope dependencies inside of GetIt
import '../auth/services/auth_service.dart' as _i6; _i174.GetIt init({
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({
String? environment, String? environment,
_i2.EnvironmentFilter? environmentFilter, _i526.EnvironmentFilter? environmentFilter,
}) async { }) {
final gh = _i2.GetItHelper( final gh = _i526.GetItHelper(
this, this,
environment, environment,
environmentFilter, environmentFilter,
); );
gh.singleton<_i3.SecureTokenStorage>(() => _i3.SecureTokenStorage()); gh.singleton<_i394.SecureTokenStorage>(() => _i394.SecureTokenStorage());
gh.singleton<_i8.DioClient>(() => _i8.DioClient()); gh.singleton<_i978.DioClient>(() => _i978.DioClient());
gh.singleton<_i5.AuthInterceptor>(() => _i5.AuthInterceptor(gh<_i3.SecureTokenStorage>())); gh.singleton<_i705.AuthApiService>(
gh.singleton<_i4.AuthApiService>(() => _i4.AuthApiService(gh<_i8.DioClient>())); () => _i705.AuthApiService(gh<_i978.DioClient>()));
gh.singleton<_i6.AuthService>(() => _i6.AuthService( gh.singleton<_i238.ApiService>(
gh<_i3.SecureTokenStorage>(), () => _i238.ApiService(gh<_i978.DioClient>()));
gh<_i4.AuthApiService>(), gh.singleton<_i772.AuthInterceptor>(
gh<_i5.AuthInterceptor>(), () => _i772.AuthInterceptor(gh<_i394.SecureTokenStorage>()));
gh<_i8.DioClient>(), 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<_i7.AuthBloc>(() => _i7.AuthBloc(gh<_i6.AuthService>())); gh.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>()));
return this; return this;
} }
} }

View File

@@ -9,7 +9,7 @@ final GetIt getIt = GetIt.instance;
/// Configure l'injection de dépendances /// Configure l'injection de dépendances
@InjectableInit() @InjectableInit()
Future<void> configureDependencies() async { Future<void> configureDependencies() async {
await getIt.init(); getIt.init();
} }
/// Réinitialise les dépendances (utile pour les tests) /// Réinitialise les dépendances (utile pour les tests)

View File

@@ -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<String, dynamic> json) =>
_$MembreModelFromJson(json);
/// Conversion vers JSON
Map<String, dynamic> toJson() => _$MembreModelToJson(this);
/// Nom complet du membre
String get nomComplet => '$prenom $nom';
/// Initiales du membre
String get initiales {
final prenomInitial = prenom.isNotEmpty ? prenom[0].toUpperCase() : '';
final nomInitial = nom.isNotEmpty ? nom[0].toUpperCase() : '';
return '$prenomInitial$nomInitial';
}
/// Adresse complète formatée
String get adresseComplete {
final parts = <String>[];
if (adresse?.isNotEmpty == true) parts.add(adresse!);
if (ville?.isNotEmpty == true) parts.add(ville!);
if (codePostal?.isNotEmpty == true) parts.add(codePostal!);
if (pays?.isNotEmpty == true) parts.add(pays!);
return parts.join(', ');
}
/// Copie avec modifications
MembreModel copyWith({
String? id,
String? numeroMembre,
String? nom,
String? prenom,
String? email,
String? telephone,
DateTime? dateNaissance,
String? adresse,
String? ville,
String? codePostal,
String? pays,
String? profession,
String? statut,
DateTime? dateAdhesion,
DateTime? dateCreation,
DateTime? dateModification,
bool? actif,
int? version,
}) {
return MembreModel(
id: id ?? this.id,
numeroMembre: numeroMembre ?? this.numeroMembre,
nom: nom ?? this.nom,
prenom: prenom ?? this.prenom,
email: email ?? this.email,
telephone: telephone ?? this.telephone,
dateNaissance: dateNaissance ?? this.dateNaissance,
adresse: adresse ?? this.adresse,
ville: ville ?? this.ville,
codePostal: codePostal ?? this.codePostal,
pays: pays ?? this.pays,
profession: profession ?? this.profession,
statut: statut ?? this.statut,
dateAdhesion: dateAdhesion ?? this.dateAdhesion,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
actif: actif ?? this.actif,
version: version ?? this.version,
);
}
@override
List<Object?> get props => [
id,
numeroMembre,
nom,
prenom,
email,
telephone,
dateNaissance,
adresse,
ville,
codePostal,
pays,
profession,
statut,
dateAdhesion,
dateCreation,
dateModification,
actif,
version,
];
@override
String toString() => 'MembreModel(id: $id, numeroMembre: $numeroMembre, '
'nomComplet: $nomComplet, email: $email, statut: $statut)';
}

View File

@@ -0,0 +1,54 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'membre_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MembreModel _$MembreModelFromJson(Map<String, dynamic> json) => MembreModel(
id: json['id'] as String?,
numeroMembre: json['numeroMembre'] as String,
nom: json['nom'] as String,
prenom: json['prenom'] as String,
email: json['email'] as String,
telephone: json['telephone'] as String,
dateNaissance: json['dateNaissance'] == null
? null
: DateTime.parse(json['dateNaissance'] as String),
adresse: json['adresse'] as String?,
ville: json['ville'] as String?,
codePostal: json['codePostal'] as String?,
pays: json['pays'] as String?,
profession: json['profession'] as String?,
statut: json['statut'] as String,
dateAdhesion: DateTime.parse(json['dateAdhesion'] as String),
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
actif: json['actif'] as bool,
version: (json['version'] as num).toInt(),
);
Map<String, dynamic> _$MembreModelToJson(MembreModel instance) =>
<String, dynamic>{
'id': instance.id,
'numeroMembre': instance.numeroMembre,
'nom': instance.nom,
'prenom': instance.prenom,
'email': instance.email,
'telephone': instance.telephone,
'dateNaissance': instance.dateNaissance?.toIso8601String(),
'adresse': instance.adresse,
'ville': instance.ville,
'codePostal': instance.codePostal,
'pays': instance.pays,
'profession': instance.profession,
'statut': instance.statut,
'dateAdhesion': instance.dateAdhesion.toIso8601String(),
'dateCreation': instance.dateCreation.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
'actif': instance.actif,
'version': instance.version,
};

View File

@@ -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<String, dynamic> json) =>
_$WaveCheckoutSessionModelFromJson(json);
/// Conversion vers JSON
Map<String, dynamic> toJson() => _$WaveCheckoutSessionModelToJson(this);
/// Montant formaté avec devise
String get montantFormate => '${montant.toStringAsFixed(0)} $devise';
/// Indique si la session est expirée
bool get estExpiree {
if (dateExpiration == null) return false;
return DateTime.now().isAfter(dateExpiration!);
}
/// Indique si la session est en attente
bool get estEnAttente => statut == 'PENDING' || statut == 'EN_ATTENTE';
/// Indique si la session est réussie
bool get estReussie => statut == 'SUCCESS' || statut == 'REUSSIE';
/// Indique si la session a échoué
bool get aEchoue => statut == 'FAILED' || statut == 'ECHEC';
/// Copie avec modifications
WaveCheckoutSessionModel copyWith({
String? id,
String? waveSessionId,
String? waveUrl,
double? montant,
String? devise,
String? successUrl,
String? errorUrl,
String? statut,
String? organisationId,
String? nomOrganisation,
String? membreId,
String? nomMembre,
String? typePaiement,
String? description,
String? referenceExterne,
DateTime? dateCreation,
DateTime? dateExpiration,
DateTime? dateModification,
bool? actif,
int? version,
}) {
return WaveCheckoutSessionModel(
id: id ?? this.id,
waveSessionId: waveSessionId ?? this.waveSessionId,
waveUrl: waveUrl ?? this.waveUrl,
montant: montant ?? this.montant,
devise: devise ?? this.devise,
successUrl: successUrl ?? this.successUrl,
errorUrl: errorUrl ?? this.errorUrl,
statut: statut ?? this.statut,
organisationId: organisationId ?? this.organisationId,
nomOrganisation: nomOrganisation ?? this.nomOrganisation,
membreId: membreId ?? this.membreId,
nomMembre: nomMembre ?? this.nomMembre,
typePaiement: typePaiement ?? this.typePaiement,
description: description ?? this.description,
referenceExterne: referenceExterne ?? this.referenceExterne,
dateCreation: dateCreation ?? this.dateCreation,
dateExpiration: dateExpiration ?? this.dateExpiration,
dateModification: dateModification ?? this.dateModification,
actif: actif ?? this.actif,
version: version ?? this.version,
);
}
@override
List<Object?> get props => [
id,
waveSessionId,
waveUrl,
montant,
devise,
successUrl,
errorUrl,
statut,
organisationId,
nomOrganisation,
membreId,
nomMembre,
typePaiement,
description,
referenceExterne,
dateCreation,
dateExpiration,
dateModification,
actif,
version,
];
@override
String toString() => 'WaveCheckoutSessionModel(id: $id, '
'waveSessionId: $waveSessionId, montant: $montantFormate, '
'statut: $statut, typePaiement: $typePaiement)';
}

View File

@@ -0,0 +1,61 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'wave_checkout_session_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
WaveCheckoutSessionModel _$WaveCheckoutSessionModelFromJson(
Map<String, dynamic> json) =>
WaveCheckoutSessionModel(
id: json['id'] as String?,
waveSessionId: json['waveSessionId'] as String,
waveUrl: json['waveUrl'] as String?,
montant: (json['montant'] as num).toDouble(),
devise: json['devise'] as String,
successUrl: json['successUrl'] as String,
errorUrl: json['errorUrl'] as String,
statut: json['statut'] as String,
organisationId: json['organisationId'] as String?,
nomOrganisation: json['nomOrganisation'] as String?,
membreId: json['membreId'] as String?,
nomMembre: json['nomMembre'] as String?,
typePaiement: json['typePaiement'] as String?,
description: json['description'] as String?,
referenceExterne: json['referenceExterne'] as String?,
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateExpiration: json['dateExpiration'] == null
? null
: DateTime.parse(json['dateExpiration'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
actif: json['actif'] as bool,
version: (json['version'] as num).toInt(),
);
Map<String, dynamic> _$WaveCheckoutSessionModelToJson(
WaveCheckoutSessionModel instance) =>
<String, dynamic>{
'id': instance.id,
'waveSessionId': instance.waveSessionId,
'waveUrl': instance.waveUrl,
'montant': instance.montant,
'devise': instance.devise,
'successUrl': instance.successUrl,
'errorUrl': instance.errorUrl,
'statut': instance.statut,
'organisationId': instance.organisationId,
'nomOrganisation': instance.nomOrganisation,
'membreId': instance.membreId,
'nomMembre': instance.nomMembre,
'typePaiement': instance.typePaiement,
'description': instance.description,
'referenceExterne': instance.referenceExterne,
'dateCreation': instance.dateCreation.toIso8601String(),
'dateExpiration': instance.dateExpiration?.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
'actif': instance.actif,
'version': instance.version,
};

View File

@@ -19,7 +19,7 @@ class DioClient {
void _configureOptions() { void _configureOptions() {
_dio.options = BaseOptions( _dio.options = BaseOptions(
// URL de base de l'API // 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 // Timeouts
connectTimeout: const Duration(seconds: 30), connectTimeout: const Duration(seconds: 30),

View File

@@ -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<List<MembreModel>> getMembres() async {
try {
final response = await _dio.get('/api/membres');
if (response.data is List) {
return (response.data as List)
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la liste des membres');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des membres');
}
}
/// Récupère un membre par son ID
Future<MembreModel> getMembreById(String id) async {
try {
final response = await _dio.get('/api/membres/$id');
return MembreModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération du membre');
}
}
/// Crée un nouveau membre
Future<MembreModel> createMembre(MembreModel membre) async {
try {
final response = await _dio.post(
'/api/membres',
data: membre.toJson(),
);
return MembreModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la création du membre');
}
}
/// Met à jour un membre existant
Future<MembreModel> updateMembre(String id, MembreModel membre) async {
try {
final response = await _dio.put(
'/api/membres/$id',
data: membre.toJson(),
);
return MembreModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la mise à jour du membre');
}
}
/// Désactive un membre
Future<void> deleteMembre(String id) async {
try {
await _dio.delete('/api/membres/$id');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la suppression du membre');
}
}
/// Recherche des membres par nom ou prénom
Future<List<MembreModel>> searchMembres(String query) async {
try {
final response = await _dio.get(
'/api/membres/recherche',
queryParameters: {'q': query},
);
if (response.data is List) {
return (response.data as List)
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la recherche');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la recherche de membres');
}
}
/// Récupère les statistiques des membres
Future<Map<String, dynamic>> getMembresStats() async {
try {
final response = await _dio.get('/api/membres/stats');
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques');
}
}
// ========================================
// PAIEMENTS WAVE
// ========================================
/// Crée une session de paiement Wave
Future<WaveCheckoutSessionModel> createWaveSession({
required double montant,
required String devise,
required String successUrl,
required String errorUrl,
String? organisationId,
String? membreId,
String? typePaiement,
String? description,
}) async {
try {
final response = await _dio.post(
'/api/paiements/wave/sessions',
data: {
'montant': montant,
'devise': devise,
'successUrl': successUrl,
'errorUrl': errorUrl,
if (organisationId != null) 'organisationId': organisationId,
if (membreId != null) 'membreId': membreId,
if (typePaiement != null) 'typePaiement': typePaiement,
if (description != null) 'description': description,
},
);
return WaveCheckoutSessionModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la création de la session Wave');
}
}
/// Récupère une session de paiement Wave par son ID
Future<WaveCheckoutSessionModel> getWaveSession(String sessionId) async {
try {
final response = await _dio.get('/api/paiements/wave/sessions/$sessionId');
return WaveCheckoutSessionModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération de la session Wave');
}
}
/// Vérifie le statut d'une session de paiement Wave
Future<String> checkWaveSessionStatus(String sessionId) async {
try {
final response = await _dio.get('/api/paiements/wave/sessions/$sessionId/status');
return response.data['statut'] as String;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la vérification du statut Wave');
}
}
// ========================================
// 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);
}
}
}

View File

@@ -6,7 +6,6 @@ import '../../../../core/auth/bloc/auth_event.dart';
import '../../../../core/auth/models/auth_state.dart'; import '../../../../core/auth/models/auth_state.dart';
import '../../../../core/auth/models/login_request.dart'; import '../../../../core/auth/models/login_request.dart';
import '../../../../shared/theme/app_theme.dart'; import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../widgets/login_form.dart'; import '../widgets/login_form.dart';
import '../widgets/login_header.dart'; import '../widgets/login_header.dart';
import '../widgets/login_footer.dart'; import '../widgets/login_footer.dart';
@@ -75,16 +74,17 @@ class _LoginPageState extends State<LoginPage>
end: 1.0, end: 1.0,
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: _shakeController, parent: _shakeController,
curve: Curves.elasticInOut, curve: Curves.elasticIn,
)); ));
} }
void _startEntryAnimation() { void _startEntryAnimation() {
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_animationController.forward(); _animationController.forward();
} }
});
void _startShakeAnimation() {
_shakeController.reset();
_shakeController.forward();
} }
@override @override
@@ -100,39 +100,46 @@ class _LoginPageState extends State<LoginPage>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.backgroundLight, backgroundColor: AppTheme.backgroundLight,
body: BlocListener<AuthBloc, AuthState>( body: BlocConsumer<AuthBloc, AuthState>(
listener: _handleAuthStateChange, listener: (context, state) {
child: SafeArea( setState(() {
child: AnimatedBuilder( _isLoading = state.status == AuthStatus.checking;
animation: _animationController, });
builder: (context, child) {
return FadeTransition( if (state.status == AuthStatus.error) {
opacity: _fadeAnimation, _startShakeAnimation();
child: Transform.translate( _showErrorSnackBar(state.errorMessage ?? 'Erreur de connexion');
offset: Offset(0, _slideAnimation.value), } else if (state.status == AuthStatus.authenticated) {
_showSuccessSnackBar('Connexion réussie !');
}
},
builder: (context, state) {
return SafeArea(
child: _buildLoginContent(), child: _buildLoginContent(),
),
); );
}, },
), ),
),
),
); );
} }
Widget _buildLoginContent() { Widget _buildLoginContent() {
return SingleChildScrollView( return AnimatedBuilder(
padding: const EdgeInsets.all(24), 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( child: Column(
children: [ children: [
const SizedBox(height: 60), const SizedBox(height: 60),
// Header avec logo et titre // Header avec logo et titre
LoginHeader( const LoginHeader(),
onAnimationComplete: () {},
),
const SizedBox(height: 60), const SizedBox(height: 40),
// Formulaire de connexion // Formulaire de connexion
AnimatedBuilder( AnimatedBuilder(
@@ -142,9 +149,23 @@ class _LoginPageState extends State<LoginPage>
offset: Offset( offset: Offset(
_shakeAnimation.value * 10 * _shakeAnimation.value * 10 *
(1 - _shakeAnimation.value) * (1 - _shakeAnimation.value) *
(1 - _shakeAnimation.value), ((_shakeAnimation.value * 10).round() % 2 == 0 ? 1 : -1),
0, 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( child: LoginForm(
formKey: _formKey, formKey: _formKey,
emailController: _emailController, emailController: _emailController,
@@ -168,6 +189,8 @@ class _LoginPageState extends State<LoginPage>
), ),
), ),
), ),
);
},
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
@@ -178,92 +201,86 @@ class _LoginPageState extends State<LoginPage>
const SizedBox(height: 20), 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() { void _handleLogin() {
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
_triggerShakeAnimation(); _startShakeAnimation();
HapticFeedback.mediumImpact();
return; return;
} }
final email = _emailController.text.trim(); HapticFeedback.lightImpact();
final password = _passwordController.text;
if (email.isEmpty || password.isEmpty) {
_showErrorMessage('Veuillez remplir tous les champs');
_triggerShakeAnimation();
return;
}
// Déclencher la connexion
final loginRequest = LoginRequest( final loginRequest = LoginRequest(
email: email, email: _emailController.text.trim(),
password: password, password: _passwordController.text,
rememberMe: _rememberMe, rememberMe: _rememberMe,
); );
context.read<AuthBloc>().add(AuthLoginRequested(loginRequest)); context.read<AuthBloc>().add(AuthLoginRequested(loginRequest));
// Feedback haptique
HapticFeedback.lightImpact();
} }
void _handleLoginError(String errorMessage) { void _showErrorSnackBar(String message) {
_showErrorMessage(errorMessage);
_triggerShakeAnimation();
HapticFeedback.mediumImpact();
// Effacer l'erreur après affichage
Future.delayed(const Duration(seconds: 3), () {
if (mounted) {
context.read<AuthBloc>().add(const AuthErrorCleared());
}
});
}
void _triggerShakeAnimation() {
_shakeController.reset();
_shakeController.forward();
}
void _showSuccessMessage() {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Row( content: Row(
children: [ children: [
Icon( const Icon(
Icons.check_circle, Icons.error_outline,
color: Colors.white, color: Colors.white,
size: 24,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( Expanded(
'Connexion réussie !', child: Text(
style: TextStyle( message,
fontWeight: FontWeight.w600, style: const TextStyle(
fontSize: 16, 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<LoginPage>
), ),
); );
} }
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();
},
),
),
);
}
} }

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart'; import '../../../../shared/theme/app_theme.dart';
import '../widgets/kpi_card.dart';
import '../widgets/clickable_kpi_card.dart'; import '../widgets/clickable_kpi_card.dart';
import '../widgets/chart_card.dart'; import '../widgets/chart_card.dart';
import '../widgets/activity_feed.dart'; import '../widgets/activity_feed.dart';

View File

@@ -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<List<MembreModel>> getMembres() async {
try {
return await _apiService.getMembres();
} catch (e) {
throw ServerFailure(message: e.toString());
}
}
@override
Future<MembreModel> getMembreById(String id) async {
try {
return await _apiService.getMembreById(id);
} catch (e) {
throw ServerFailure(message: e.toString());
}
}
@override
Future<MembreModel> createMembre(MembreModel membre) async {
try {
return await _apiService.createMembre(membre);
} catch (e) {
throw ServerFailure(message: e.toString());
}
}
@override
Future<MembreModel> updateMembre(String id, MembreModel membre) async {
try {
return await _apiService.updateMembre(id, membre);
} catch (e) {
throw ServerFailure(message: e.toString());
}
}
@override
Future<void> deleteMembre(String id) async {
try {
await _apiService.deleteMembre(id);
} catch (e) {
throw ServerFailure(message: e.toString());
}
}
@override
Future<List<MembreModel>> searchMembres(String query) async {
try {
return await _apiService.searchMembres(query);
} catch (e) {
throw ServerFailure(message: e.toString());
}
}
@override
Future<Map<String, dynamic>> getMembresStats() async {
try {
return await _apiService.getMembresStats();
} catch (e) {
throw ServerFailure(message: e.toString());
}
}
}

View File

@@ -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<List<MembreModel>> getMembres();
/// Récupère un membre par son ID
Future<MembreModel> getMembreById(String id);
/// Crée un nouveau membre
Future<MembreModel> createMembre(MembreModel membre);
/// Met à jour un membre existant
Future<MembreModel> updateMembre(String id, MembreModel membre);
/// Désactive un membre
Future<void> deleteMembre(String id);
/// Recherche des membres par nom ou prénom
Future<List<MembreModel>> searchMembres(String query);
/// Récupère les statistiques des membres
Future<Map<String, dynamic>> getMembresStats();
}

View File

@@ -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<MembresEvent, MembresState> {
final MembreRepository _membreRepository;
MembresBloc(this._membreRepository) : super(const MembresInitial()) {
// Enregistrement des handlers d'événements
on<LoadMembres>(_onLoadMembres);
on<RefreshMembres>(_onRefreshMembres);
on<SearchMembres>(_onSearchMembres);
on<LoadMembreById>(_onLoadMembreById);
on<CreateMembre>(_onCreateMembre);
on<UpdateMembre>(_onUpdateMembre);
on<DeleteMembre>(_onDeleteMembre);
on<LoadMembresStats>(_onLoadMembresStats);
on<ClearMembresError>(_onClearMembresError);
on<ResetMembresState>(_onResetMembresState);
}
/// Handler pour charger la liste des membres
Future<void> _onLoadMembres(
LoadMembres event,
Emitter<MembresState> 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<void> _onRefreshMembres(
RefreshMembres event,
Emitter<MembresState> emit,
) async {
// Conserver les données actuelles pendant le refresh
final currentState = state;
List<MembreModel> 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<void> _onSearchMembres(
SearchMembres event,
Emitter<MembresState> 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<void> _onLoadMembreById(
LoadMembreById event,
Emitter<MembresState> 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<void> _onCreateMembre(
CreateMembre event,
Emitter<MembresState> 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<void> _onUpdateMembre(
UpdateMembre event,
Emitter<MembresState> 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<void> _onDeleteMembre(
DeleteMembre event,
Emitter<MembresState> 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<void> _onLoadMembresStats(
LoadMembresStats event,
Emitter<MembresState> 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<MembresState> 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<MembresState> 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);
}
}

View File

@@ -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<Object?> 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<Object?> get props => [query];
}
/// Événement pour charger un membre spécifique
class LoadMembreById extends MembresEvent {
const LoadMembreById(this.id);
final String id;
@override
List<Object?> get props => [id];
}
/// Événement pour créer un nouveau membre
class CreateMembre extends MembresEvent {
const CreateMembre(this.membre);
final MembreModel membre;
@override
List<Object?> 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<Object?> get props => [id, membre];
}
/// Événement pour supprimer un membre
class DeleteMembre extends MembresEvent {
const DeleteMembre(this.id);
final String id;
@override
List<Object?> 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();
}

View File

@@ -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<Object?> 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<MembreModel> currentMembres;
@override
List<Object?> 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<MembreModel> membres;
final bool isSearchResult;
final String? searchQuery;
@override
List<Object?> get props => [membres, isSearchResult, searchQuery];
/// Copie avec modifications
MembresLoaded copyWith({
List<MembreModel>? 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<Object?> get props => [membre];
}
/// État de succès pour les statistiques
class MembresStatsLoaded extends MembresState {
const MembresStatsLoaded(this.stats);
final Map<String, dynamic> stats;
@override
List<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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<MembreModel> membres;
final bool isSearchResult;
final String? searchQuery;
@override
List<Object?> get props => [failure, membres, isSearchResult, searchQuery];
/// Message d'erreur formaté
String get message => failure.message;
}

View File

@@ -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<MembresListPage> createState() => _MembresListPageState();
}
class _MembresListPageState extends State<MembresListPage> {
final RefreshController _refreshController = RefreshController();
final TextEditingController _searchController = TextEditingController();
late MembresBloc _membresBloc;
@override
void initState() {
super.initState();
_membresBloc = getIt<MembresBloc>();
_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<MembresBloc, MembresState>(
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<Color>(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,
),
);
}
}

View File

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

View File

@@ -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<String> onSearch;
final VoidCallback onClear;
final String hintText;
@override
State<MembresSearchBar> createState() => _MembresSearchBarState();
}
class _MembresSearchBarState extends State<MembresSearchBar> {
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,
),
),
);
}
}

View File

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

View File

@@ -4,7 +4,7 @@ import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/coming_soon_page.dart'; import '../../../../shared/widgets/coming_soon_page.dart';
import '../../../../shared/widgets/buttons/buttons.dart'; import '../../../../shared/widgets/buttons/buttons.dart';
import '../../../dashboard/presentation/pages/enhanced_dashboard.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'; import '../widgets/custom_bottom_nav_bar.dart';
class MainNavigation extends StatefulWidget { class MainNavigation extends StatefulWidget {
@@ -17,14 +17,12 @@ class MainNavigation extends StatefulWidget {
class _MainNavigationState extends State<MainNavigation> class _MainNavigationState extends State<MainNavigation>
with TickerProviderStateMixin { with TickerProviderStateMixin {
int _currentIndex = 0; int _currentIndex = 0;
late PageController _pageController;
late AnimationController _fabAnimationController; late AnimationController _fabAnimationController;
late Animation<double> _fabAnimation; late Animation<double> _fabAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pageController = PageController();
_fabAnimationController = AnimationController( _fabAnimationController = AnimationController(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -44,7 +42,6 @@ class _MainNavigationState extends State<MainNavigation>
@override @override
void dispose() { void dispose() {
_pageController.dispose();
_fabAnimationController.dispose(); _fabAnimationController.dispose();
super.dispose(); super.dispose();
} }
@@ -85,9 +82,8 @@ class _MainNavigationState extends State<MainNavigation>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: PageView( body: IndexedStack(
controller: _pageController, index: _currentIndex,
onPageChanged: _onPageChanged,
children: [ children: [
EnhancedDashboard( EnhancedDashboard(
onNavigateToTab: _onTabTapped, onNavigateToTab: _onTabTapped,
@@ -151,7 +147,10 @@ class _MainNavigationState extends State<MainNavigation>
} }
} }
void _onPageChanged(int index) {
void _onTabTapped(int index) {
if (_currentIndex != index) {
setState(() { setState(() {
_currentIndex = index; _currentIndex = index;
}); });
@@ -166,19 +165,6 @@ class _MainNavigationState extends State<MainNavigation>
// Vibration légère // Vibration légère
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
} }
void _onTabTapped(int index) {
if (_currentIndex != index) {
setState(() {
_currentIndex = index;
});
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
} }
void _onFabPressed() { void _onFabPressed() {
@@ -219,11 +205,11 @@ class _MainNavigationState extends State<MainNavigation>
} }
Widget _buildMembresPage() { Widget _buildMembresPage() {
return MembersListPage(); return const MembresListPage();
} }
Widget _buildCotisationsPage() { Widget _buildCotisationsPage() {
return ComingSoonPage( return const ComingSoonPage(
title: 'Module Cotisations', title: 'Module Cotisations',
description: 'Suivi et gestion des cotisations avec paiements automatiques', description: 'Suivi et gestion des cotisations avec paiements automatiques',
icon: Icons.payment_rounded, icon: Icons.payment_rounded,
@@ -240,7 +226,7 @@ class _MainNavigationState extends State<MainNavigation>
} }
Widget _buildEventsPage() { Widget _buildEventsPage() {
return ComingSoonPage( return const ComingSoonPage(
title: 'Module Événements', title: 'Module Événements',
description: 'Organisation et gestion d\'événements avec calendrier intégré', description: 'Organisation et gestion d\'événements avec calendrier intégré',
icon: Icons.event_rounded, icon: Icons.event_rounded,
@@ -257,21 +243,42 @@ class _MainNavigationState extends State<MainNavigation>
} }
Widget _buildMorePage() { Widget _buildMorePage() {
return Scaffold( return Container(
backgroundColor: AppTheme.backgroundLight, color: AppTheme.backgroundLight,
appBar: AppBar( child: Column(
title: const Text('Plus'), children: [
backgroundColor: AppTheme.infoColor, // Header personnalisé au lieu d'AppBar
elevation: 0, Container(
automaticallyImplyLeading: false, width: double.infinity,
actions: [ 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( IconButton(
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings, color: Colors.white),
onPressed: () {}, onPressed: () {},
), ),
], ],
), ),
body: ListView( ),
// Contenu scrollable
Expanded(
child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildMoreSection( _buildMoreSection(
@@ -305,6 +312,9 @@ class _MainNavigationState extends State<MainNavigation>
), ),
], ],
), ),
),
],
),
); );
} }

View File

@@ -5,12 +5,16 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/auth/bloc/temp_auth_bloc.dart'; import 'core/auth/bloc/temp_auth_bloc.dart';
import 'core/auth/bloc/auth_event.dart'; import 'core/auth/bloc/auth_event.dart';
import 'core/auth/services/temp_auth_service.dart'; import 'core/auth/services/temp_auth_service.dart';
import 'core/di/injection.dart';
import 'shared/theme/app_theme.dart'; import 'shared/theme/app_theme.dart';
import 'app_temp.dart'; import 'app_temp.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Configuration de l'injection de dépendances
await configureDependencies();
// Configuration du système // Configuration du système
await _configureApp(); await _configureApp();

View File

@@ -4,13 +4,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/auth/bloc/temp_auth_bloc.dart'; import 'core/auth/bloc/temp_auth_bloc.dart';
import 'core/auth/bloc/auth_event.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 'shared/theme/app_theme.dart';
import 'app_ultra_simple.dart'; import 'app_ultra_simple.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Configuration de l'injection de dépendances
await configureDependencies();
// Configuration du système // Configuration du système
await _configureApp(); await _configureApp();
@@ -39,7 +44,7 @@ Future<void> _configureApp() async {
/// Classe BLoC ultra-simple qui utilise UltraSimpleAuthService /// Classe BLoC ultra-simple qui utilise UltraSimpleAuthService
class UltraSimpleAuthBloc extends TempAuthBloc { class UltraSimpleAuthBloc extends TempAuthBloc {
UltraSimpleAuthBloc(UltraSimpleAuthService authService) : super(authService); UltraSimpleAuthBloc(TempAuthService authService) : super(authService);
} }
/// Application principale ultra-simple /// Application principale ultra-simple
@@ -50,7 +55,7 @@ class UnionFlowUltraSimpleApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<UltraSimpleAuthBloc>( return BlocProvider<UltraSimpleAuthBloc>(
create: (context) { create: (context) {
final authService = UltraSimpleAuthService(); final authService = TempAuthService();
final authBloc = UltraSimpleAuthBloc(authService); final authBloc = UltraSimpleAuthBloc(authService);
authBloc.add(const AuthInitializeRequested()); authBloc.add(const AuthInitializeRequested());
return authBloc; return authBloc;

View File

@@ -118,6 +118,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.11.1" 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: characters:
dependency: transitive dependency: transitive
description: description:
@@ -267,6 +291,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.6" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -275,6 +307,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -317,6 +397,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" 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: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -374,13 +462,21 @@ packages:
source: hosted source: hosted
version: "0.6.7" version: "0.6.7"
json_annotation: json_annotation:
dependency: transitive dependency: "direct main"
description: description:
name: json_annotation name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.9.0" 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: jwt_decoder:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -469,6 +565,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
mockito:
dependency: "direct dev"
description:
name: mockito
sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917"
url: "https://pub.dev"
source: hosted
version: "5.4.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -477,6 +581,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" 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: package_config:
dependency: transitive dependency: transitive
description: description:
@@ -485,6 +597,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@@ -493,6 +621,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -573,6 +725,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: recase:
dependency: transitive dependency: transitive
description: description:
@@ -581,6 +741,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -653,6 +821,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -666,6 +842,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.0" 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: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -674,6 +858,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.0" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -706,6 +938,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" 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: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -738,6 +978,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -786,6 +1098,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e
url: "https://pub.dev"
source: hosted
version: "5.10.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@@ -20,7 +20,7 @@ dependencies:
intl: ^0.19.0 intl: ^0.19.0
# Authentication (versions compatibles) # 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 jwt_decoder: ^2.0.1
crypto: ^3.0.5 crypto: ^3.0.5
shared_preferences: ^2.3.2 shared_preferences: ^2.3.2
@@ -32,12 +32,27 @@ dependencies:
get_it: ^7.7.0 get_it: ^7.7.0
injectable: ^2.4.4 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^4.0.0 flutter_lints: ^4.0.0
injectable_generator: ^2.6.2 injectable_generator: ^2.6.2
build_runner: ^2.4.13 build_runner: ^2.4.13
json_serializable: ^6.8.0
mockito: ^5.4.4
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -432,6 +432,31 @@ class FormuleAbonnementDTOBasicTest {
formule.setPersonnalisationInterface(false); formule.setPersonnalisationInterface(false);
// Score = (10 + 15) * 100 / 100 = 25 // Score = (10 + 15) * 100 / 100 = 25
assertThat(formule.getScoreFonctionnalites()).isEqualTo(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 @Test

View File

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

View File

@@ -80,15 +80,18 @@ quarkus:
"%dev": "%dev":
quarkus: quarkus:
datasource: datasource:
username: unionflow_dev db-kind: h2
password: dev123 username: sa
password: ""
jdbc: jdbc:
url: jdbc:postgresql://localhost:5432/unionflow_dev url: jdbc:h2:mem:unionflow_dev;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
hibernate-orm: hibernate-orm:
database: database:
generation: drop-and-create generation: drop-and-create
log: log:
sql: true sql: true
flyway:
migrate-at-start: false
log: log:
category: category:
"dev.lions.unionflow": DEBUG "dev.lions.unionflow": DEBUG