Clean project: remove test files, debug logs, and add documentation

This commit is contained in:
dahoud
2025-10-05 13:41:33 +00:00
parent 96a17eadbd
commit 291847924c
438 changed files with 65754 additions and 32713 deletions

View File

@@ -0,0 +1,419 @@
/// BLoC pour la gestion des membres
library membres_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:dio/dio.dart';
import 'membres_event.dart';
import 'membres_state.dart';
import '../data/repositories/membre_repository_impl.dart';
/// BLoC pour la gestion des membres
class MembresBloc extends Bloc<MembresEvent, MembresState> {
final MembreRepository _repository;
MembresBloc(this._repository) : super(const MembresInitial()) {
on<LoadMembres>(_onLoadMembres);
on<LoadMembreById>(_onLoadMembreById);
on<CreateMembre>(_onCreateMembre);
on<UpdateMembre>(_onUpdateMembre);
on<DeleteMembre>(_onDeleteMembre);
on<ActivateMembre>(_onActivateMembre);
on<DeactivateMembre>(_onDeactivateMembre);
on<SearchMembres>(_onSearchMembres);
on<LoadActiveMembres>(_onLoadActiveMembres);
on<LoadBureauMembres>(_onLoadBureauMembres);
on<LoadMembresStats>(_onLoadMembresStats);
}
/// Charge la liste des membres
Future<void> _onLoadMembres(
LoadMembres event,
Emitter<MembresState> emit,
) async {
try {
// Si refresh et qu'on a déjà des données, on garde l'état actuel
if (event.refresh && state is MembresLoaded) {
final currentState = state as MembresLoaded;
emit(MembresRefreshing(currentState.membres));
} else {
emit(const MembresLoading());
}
final result = await _repository.getMembres(
page: event.page,
size: event.size,
recherche: event.recherche,
);
emit(MembresLoaded(
membres: result.membres,
totalElements: result.totalElements,
currentPage: result.currentPage,
pageSize: result.pageSize,
totalPages: result.totalPages,
));
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur inattendue lors du chargement des membres: $e',
error: e,
));
}
}
/// Charge un membre par ID
Future<void> _onLoadMembreById(
LoadMembreById event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final membre = await _repository.getMembreById(event.id);
if (membre != null) {
emit(MembreDetailLoaded(membre));
} else {
emit(const MembresError(
message: 'Membre non trouvé',
code: '404',
));
}
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur lors du chargement du membre: $e',
error: e,
));
}
}
/// Crée un nouveau membre
Future<void> _onCreateMembre(
CreateMembre event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final membre = await _repository.createMembre(event.membre);
emit(MembreCreated(membre));
} on DioException catch (e) {
if (e.response?.statusCode == 400) {
// Erreur de validation
final errors = _extractValidationErrors(e.response?.data);
emit(MembresValidationError(
message: 'Erreur de validation',
validationErrors: errors,
code: '400',
));
} else {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
}
} catch (e) {
emit(MembresError(
message: 'Erreur lors de la création du membre: $e',
error: e,
));
}
}
/// Met à jour un membre
Future<void> _onUpdateMembre(
UpdateMembre event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final membre = await _repository.updateMembre(event.id, event.membre);
emit(MembreUpdated(membre));
} on DioException catch (e) {
if (e.response?.statusCode == 400) {
final errors = _extractValidationErrors(e.response?.data);
emit(MembresValidationError(
message: 'Erreur de validation',
validationErrors: errors,
code: '400',
));
} else {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
}
} catch (e) {
emit(MembresError(
message: 'Erreur lors de la mise à jour du membre: $e',
error: e,
));
}
}
/// Supprime un membre
Future<void> _onDeleteMembre(
DeleteMembre event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
await _repository.deleteMembre(event.id);
emit(MembreDeleted(event.id));
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur lors de la suppression du membre: $e',
error: e,
));
}
}
/// Active un membre
Future<void> _onActivateMembre(
ActivateMembre event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final membre = await _repository.activateMembre(event.id);
emit(MembreActivated(membre));
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur lors de l\'activation du membre: $e',
error: e,
));
}
}
/// Désactive un membre
Future<void> _onDeactivateMembre(
DeactivateMembre event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final membre = await _repository.deactivateMembre(event.id);
emit(MembreDeactivated(membre));
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur lors de la désactivation du membre: $e',
error: e,
));
}
}
/// Recherche avancée de membres
Future<void> _onSearchMembres(
SearchMembres event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final result = await _repository.searchMembres(
criteria: event.criteria,
page: event.page,
size: event.size,
);
emit(MembresLoaded(
membres: result.membres,
totalElements: result.totalElements,
currentPage: result.currentPage,
pageSize: result.pageSize,
totalPages: result.totalPages,
));
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur lors de la recherche de membres: $e',
error: e,
));
}
}
/// Charge les membres actifs
Future<void> _onLoadActiveMembres(
LoadActiveMembres event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final result = await _repository.getActiveMembers(
page: event.page,
size: event.size,
);
emit(MembresLoaded(
membres: result.membres,
totalElements: result.totalElements,
currentPage: result.currentPage,
pageSize: result.pageSize,
totalPages: result.totalPages,
));
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur lors du chargement des membres actifs: $e',
error: e,
));
}
}
/// Charge les membres du bureau
Future<void> _onLoadBureauMembres(
LoadBureauMembres event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final result = await _repository.getBureauMembers(
page: event.page,
size: event.size,
);
emit(MembresLoaded(
membres: result.membres,
totalElements: result.totalElements,
currentPage: result.currentPage,
pageSize: result.pageSize,
totalPages: result.totalPages,
));
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur lors du chargement des membres du bureau: $e',
error: e,
));
}
}
/// Charge les statistiques
Future<void> _onLoadMembresStats(
LoadMembresStats event,
Emitter<MembresState> emit,
) async {
try {
emit(const MembresLoading());
final stats = await _repository.getMembresStats();
emit(MembresStatsLoaded(stats));
} on DioException catch (e) {
emit(MembresNetworkError(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
error: e,
));
} catch (e) {
emit(MembresError(
message: 'Erreur lors du chargement des statistiques: $e',
error: e,
));
}
}
/// Extrait les erreurs de validation de la réponse
Map<String, String> _extractValidationErrors(dynamic data) {
final errors = <String, String>{};
if (data is Map<String, dynamic> && data.containsKey('errors')) {
final errorsData = data['errors'];
if (errorsData is Map<String, dynamic>) {
errorsData.forEach((key, value) {
errors[key] = value.toString();
});
}
}
return errors;
}
/// Génère un message d'erreur réseau approprié
String _getNetworkErrorMessage(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
return 'Délai de connexion dépassé. Vérifiez votre connexion internet.';
case DioExceptionType.sendTimeout:
return 'Délai d\'envoi dépassé. Vérifiez votre connexion internet.';
case DioExceptionType.receiveTimeout:
return 'Délai de réception dépassé. Vérifiez votre connexion internet.';
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
if (statusCode == 401) {
return 'Non autorisé. Veuillez vous reconnecter.';
} else if (statusCode == 403) {
return 'Accès refusé. Vous n\'avez pas les permissions nécessaires.';
} else if (statusCode == 404) {
return 'Ressource non trouvée.';
} else if (statusCode == 409) {
return 'Conflit. Cette ressource existe déjà.';
} else if (statusCode != null && statusCode >= 500) {
return 'Erreur serveur. Veuillez réessayer plus tard.';
}
return 'Erreur lors de la communication avec le serveur.';
case DioExceptionType.cancel:
return 'Requête annulée.';
case DioExceptionType.unknown:
return 'Erreur de connexion. Vérifiez votre connexion internet.';
default:
return 'Erreur réseau inattendue.';
}
}
}

View File

@@ -0,0 +1,143 @@
/// Événements pour le BLoC des membres
library membres_event;
import 'package:equatable/equatable.dart';
import '../data/models/membre_complete_model.dart';
import '../../../core/models/membre_search_criteria.dart';
/// Classe de base pour tous les événements 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 {
final int page;
final int size;
final String? recherche;
final bool refresh;
const LoadMembres({
this.page = 0,
this.size = 20,
this.recherche,
this.refresh = false,
});
@override
List<Object?> get props => [page, size, recherche, refresh];
}
/// Événement pour charger un membre par ID
class LoadMembreById extends MembresEvent {
final String id;
const LoadMembreById(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour créer un nouveau membre
class CreateMembre extends MembresEvent {
final MembreCompletModel membre;
const CreateMembre(this.membre);
@override
List<Object?> get props => [membre];
}
/// Événement pour mettre à jour un membre
class UpdateMembre extends MembresEvent {
final String id;
final MembreCompletModel membre;
const UpdateMembre(this.id, this.membre);
@override
List<Object?> get props => [id, membre];
}
/// Événement pour supprimer un membre
class DeleteMembre extends MembresEvent {
final String id;
const DeleteMembre(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour activer un membre
class ActivateMembre extends MembresEvent {
final String id;
const ActivateMembre(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour désactiver un membre
class DeactivateMembre extends MembresEvent {
final String id;
const DeactivateMembre(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour recherche avancée
class SearchMembres extends MembresEvent {
final MembreSearchCriteria criteria;
final int page;
final int size;
const SearchMembres({
required this.criteria,
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [criteria, page, size];
}
/// Événement pour charger les membres actifs
class LoadActiveMembres extends MembresEvent {
final int page;
final int size;
const LoadActiveMembres({
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [page, size];
}
/// Événement pour charger les membres du bureau
class LoadBureauMembres extends MembresEvent {
final int page;
final int size;
const LoadBureauMembres({
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [page, size];
}
/// Événement pour charger les statistiques
class LoadMembresStats extends MembresEvent {
const LoadMembresStats();
}

View File

@@ -0,0 +1,180 @@
/// États pour le BLoC des membres
library membres_state;
import 'package:equatable/equatable.dart';
import '../data/models/membre_complete_model.dart';
/// Classe de base pour tous les états 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 refresh)
class MembresRefreshing extends MembresState {
final List<MembreCompletModel> currentMembres;
const MembresRefreshing(this.currentMembres);
@override
List<Object?> get props => [currentMembres];
}
/// État de succès avec liste de membres
class MembresLoaded extends MembresState {
final List<MembreCompletModel> membres;
final int totalElements;
final int currentPage;
final int pageSize;
final int totalPages;
final bool hasMore;
const MembresLoaded({
required this.membres,
required this.totalElements,
this.currentPage = 0,
this.pageSize = 20,
required this.totalPages,
}) : hasMore = currentPage < totalPages - 1;
@override
List<Object?> get props => [membres, totalElements, currentPage, pageSize, totalPages, hasMore];
MembresLoaded copyWith({
List<MembreCompletModel>? membres,
int? totalElements,
int? currentPage,
int? pageSize,
int? totalPages,
}) {
return MembresLoaded(
membres: membres ?? this.membres,
totalElements: totalElements ?? this.totalElements,
currentPage: currentPage ?? this.currentPage,
pageSize: pageSize ?? this.pageSize,
totalPages: totalPages ?? this.totalPages,
);
}
}
/// État de succès avec un seul membre
class MembreDetailLoaded extends MembresState {
final MembreCompletModel membre;
const MembreDetailLoaded(this.membre);
@override
List<Object?> get props => [membre];
}
/// État de succès après création
class MembreCreated extends MembresState {
final MembreCompletModel membre;
const MembreCreated(this.membre);
@override
List<Object?> get props => [membre];
}
/// État de succès après mise à jour
class MembreUpdated extends MembresState {
final MembreCompletModel membre;
const MembreUpdated(this.membre);
@override
List<Object?> get props => [membre];
}
/// État de succès après suppression
class MembreDeleted extends MembresState {
final String id;
const MembreDeleted(this.id);
@override
List<Object?> get props => [id];
}
/// État de succès après activation
class MembreActivated extends MembresState {
final MembreCompletModel membre;
const MembreActivated(this.membre);
@override
List<Object?> get props => [membre];
}
/// État de succès après désactivation
class MembreDeactivated extends MembresState {
final MembreCompletModel membre;
const MembreDeactivated(this.membre);
@override
List<Object?> get props => [membre];
}
/// État avec statistiques
class MembresStatsLoaded extends MembresState {
final Map<String, dynamic> stats;
const MembresStatsLoaded(this.stats);
@override
List<Object?> get props => [stats];
}
/// État d'erreur
class MembresError extends MembresState {
final String message;
final String? code;
final dynamic error;
const MembresError({
required this.message,
this.code,
this.error,
});
@override
List<Object?> get props => [message, code, error];
}
/// État d'erreur réseau
class MembresNetworkError extends MembresError {
const MembresNetworkError({
required String message,
String? code,
dynamic error,
}) : super(message: message, code: code, error: error);
}
/// État d'erreur de validation
class MembresValidationError extends MembresError {
final Map<String, String> validationErrors;
const MembresValidationError({
required String message,
required this.validationErrors,
String? code,
}) : super(message: message, code: code);
@override
List<Object?> get props => [message, code, validationErrors];
}

View File

@@ -0,0 +1,329 @@
/// Modèle complet de données pour un membre
/// Aligné avec le backend MembreDTO
library membre_complete_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'membre_complete_model.g.dart';
/// Énumération des genres
enum Genre {
@JsonValue('HOMME')
homme,
@JsonValue('FEMME')
femme,
@JsonValue('AUTRE')
autre,
}
/// Énumération des statuts de membre
enum StatutMembre {
@JsonValue('ACTIF')
actif,
@JsonValue('INACTIF')
inactif,
@JsonValue('SUSPENDU')
suspendu,
@JsonValue('EN_ATTENTE')
enAttente,
}
/// Modèle complet d'un membre
@JsonSerializable()
class MembreCompletModel extends Equatable {
/// Identifiant unique
final String? id;
/// Nom de famille
final String nom;
/// Prénom
final String prenom;
/// Email (unique)
final String email;
/// Téléphone
final String? telephone;
/// Date de naissance
@JsonKey(name: 'dateNaissance')
final DateTime? dateNaissance;
/// Genre
final Genre? genre;
/// Adresse complète
final String? adresse;
/// Ville
final String? ville;
/// Code postal
@JsonKey(name: 'codePostal')
final String? codePostal;
/// Région
final String? region;
/// Pays
final String? pays;
/// Profession
final String? profession;
/// Nationalité
final String? nationalite;
/// URL de la photo
final String? photo;
/// Statut du membre
final StatutMembre statut;
/// Rôle dans l'organisation
final String? role;
/// ID de l'organisation
@JsonKey(name: 'organisationId')
final String? organisationId;
/// Nom de l'organisation (pour affichage)
@JsonKey(name: 'organisationNom')
final String? organisationNom;
/// Date d'adhésion
@JsonKey(name: 'dateAdhesion')
final DateTime? dateAdhesion;
/// Date de fin d'adhésion
@JsonKey(name: 'dateFinAdhesion')
final DateTime? dateFinAdhesion;
/// Membre du bureau
@JsonKey(name: 'membreBureau')
final bool membreBureau;
/// Est responsable
final bool responsable;
/// Fonction au bureau
@JsonKey(name: 'fonctionBureau')
final String? fonctionBureau;
/// Numéro de membre (unique)
@JsonKey(name: 'numeroMembre')
final String? numeroMembre;
/// Cotisation à jour
@JsonKey(name: 'cotisationAJour')
final bool cotisationAJour;
/// Nombre d'événements participés
@JsonKey(name: 'nombreEvenementsParticipes')
final int nombreEvenementsParticipes;
/// Dernière activité
@JsonKey(name: 'derniereActivite')
final DateTime? derniereActivite;
/// Notes internes
final String? notes;
/// Date de création
@JsonKey(name: 'dateCreation')
final DateTime? dateCreation;
/// Date de modification
@JsonKey(name: 'dateModification')
final DateTime? dateModification;
/// Actif
final bool actif;
const MembreCompletModel({
this.id,
required this.nom,
required this.prenom,
required this.email,
this.telephone,
this.dateNaissance,
this.genre,
this.adresse,
this.ville,
this.codePostal,
this.region,
this.pays,
this.profession,
this.nationalite,
this.photo,
this.statut = StatutMembre.actif,
this.role,
this.organisationId,
this.organisationNom,
this.dateAdhesion,
this.dateFinAdhesion,
this.membreBureau = false,
this.responsable = false,
this.fonctionBureau,
this.numeroMembre,
this.cotisationAJour = false,
this.nombreEvenementsParticipes = 0,
this.derniereActivite,
this.notes,
this.dateCreation,
this.dateModification,
this.actif = true,
});
/// Création depuis JSON
factory MembreCompletModel.fromJson(Map<String, dynamic> json) =>
_$MembreCompletModelFromJson(json);
/// Conversion vers JSON
Map<String, dynamic> toJson() => _$MembreCompletModelToJson(this);
/// Copie avec modifications
MembreCompletModel copyWith({
String? id,
String? nom,
String? prenom,
String? email,
String? telephone,
DateTime? dateNaissance,
Genre? genre,
String? adresse,
String? ville,
String? codePostal,
String? region,
String? pays,
String? profession,
String? nationalite,
String? photo,
StatutMembre? statut,
String? role,
String? organisationId,
String? organisationNom,
DateTime? dateAdhesion,
DateTime? dateFinAdhesion,
bool? membreBureau,
bool? responsable,
String? fonctionBureau,
String? numeroMembre,
bool? cotisationAJour,
int? nombreEvenementsParticipes,
DateTime? derniereActivite,
String? notes,
DateTime? dateCreation,
DateTime? dateModification,
bool? actif,
}) {
return MembreCompletModel(
id: id ?? this.id,
nom: nom ?? this.nom,
prenom: prenom ?? this.prenom,
email: email ?? this.email,
telephone: telephone ?? this.telephone,
dateNaissance: dateNaissance ?? this.dateNaissance,
genre: genre ?? this.genre,
adresse: adresse ?? this.adresse,
ville: ville ?? this.ville,
codePostal: codePostal ?? this.codePostal,
region: region ?? this.region,
pays: pays ?? this.pays,
profession: profession ?? this.profession,
nationalite: nationalite ?? this.nationalite,
photo: photo ?? this.photo,
statut: statut ?? this.statut,
role: role ?? this.role,
organisationId: organisationId ?? this.organisationId,
organisationNom: organisationNom ?? this.organisationNom,
dateAdhesion: dateAdhesion ?? this.dateAdhesion,
dateFinAdhesion: dateFinAdhesion ?? this.dateFinAdhesion,
membreBureau: membreBureau ?? this.membreBureau,
responsable: responsable ?? this.responsable,
fonctionBureau: fonctionBureau ?? this.fonctionBureau,
numeroMembre: numeroMembre ?? this.numeroMembre,
cotisationAJour: cotisationAJour ?? this.cotisationAJour,
nombreEvenementsParticipes: nombreEvenementsParticipes ?? this.nombreEvenementsParticipes,
derniereActivite: derniereActivite ?? this.derniereActivite,
notes: notes ?? this.notes,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
actif: actif ?? this.actif,
);
}
/// Nom complet
String get nomComplet => '$prenom $nom';
/// Initiales
String get initiales {
final p = prenom.isNotEmpty ? prenom[0].toUpperCase() : '';
final n = nom.isNotEmpty ? nom[0].toUpperCase() : '';
return '$p$n';
}
/// Âge calculé
int? get age {
if (dateNaissance == null) return null;
final now = DateTime.now();
int age = now.year - dateNaissance!.year;
if (now.month < dateNaissance!.month ||
(now.month == dateNaissance!.month && now.day < dateNaissance!.day)) {
age--;
}
return age;
}
/// Ancienneté en jours
int? get ancienneteJours {
if (dateAdhesion == null) return null;
return DateTime.now().difference(dateAdhesion!).inDays;
}
/// Est actif et cotisation à jour
bool get estActifEtAJour => actif && statut == StatutMembre.actif && cotisationAJour;
@override
List<Object?> get props => [
id,
nom,
prenom,
email,
telephone,
dateNaissance,
genre,
adresse,
ville,
codePostal,
region,
pays,
profession,
nationalite,
photo,
statut,
role,
organisationId,
organisationNom,
dateAdhesion,
dateFinAdhesion,
membreBureau,
responsable,
fonctionBureau,
numeroMembre,
cotisationAJour,
nombreEvenementsParticipes,
derniereActivite,
notes,
dateCreation,
dateModification,
actif,
];
@override
String toString() =>
'MembreCompletModel(id: $id, nom: $nomComplet, email: $email, statut: $statut)';
}

View File

@@ -0,0 +1,106 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'membre_complete_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MembreCompletModel _$MembreCompletModelFromJson(Map<String, dynamic> json) =>
MembreCompletModel(
id: json['id'] 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),
genre: $enumDecodeNullable(_$GenreEnumMap, json['genre']),
adresse: json['adresse'] as String?,
ville: json['ville'] as String?,
codePostal: json['codePostal'] as String?,
region: json['region'] as String?,
pays: json['pays'] as String?,
profession: json['profession'] as String?,
nationalite: json['nationalite'] as String?,
photo: json['photo'] as String?,
statut: $enumDecodeNullable(_$StatutMembreEnumMap, json['statut']) ??
StatutMembre.actif,
role: json['role'] as String?,
organisationId: json['organisationId'] as String?,
organisationNom: json['organisationNom'] as String?,
dateAdhesion: json['dateAdhesion'] == null
? null
: DateTime.parse(json['dateAdhesion'] as String),
dateFinAdhesion: json['dateFinAdhesion'] == null
? null
: DateTime.parse(json['dateFinAdhesion'] as String),
membreBureau: json['membreBureau'] as bool? ?? false,
responsable: json['responsable'] as bool? ?? false,
fonctionBureau: json['fonctionBureau'] as String?,
numeroMembre: json['numeroMembre'] as String?,
cotisationAJour: json['cotisationAJour'] as bool? ?? false,
nombreEvenementsParticipes:
(json['nombreEvenementsParticipes'] as num?)?.toInt() ?? 0,
derniereActivite: json['derniereActivite'] == null
? null
: DateTime.parse(json['derniereActivite'] as String),
notes: json['notes'] as String?,
dateCreation: json['dateCreation'] == null
? null
: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
actif: json['actif'] as bool? ?? true,
);
Map<String, dynamic> _$MembreCompletModelToJson(MembreCompletModel instance) =>
<String, dynamic>{
'id': instance.id,
'nom': instance.nom,
'prenom': instance.prenom,
'email': instance.email,
'telephone': instance.telephone,
'dateNaissance': instance.dateNaissance?.toIso8601String(),
'genre': _$GenreEnumMap[instance.genre],
'adresse': instance.adresse,
'ville': instance.ville,
'codePostal': instance.codePostal,
'region': instance.region,
'pays': instance.pays,
'profession': instance.profession,
'nationalite': instance.nationalite,
'photo': instance.photo,
'statut': _$StatutMembreEnumMap[instance.statut]!,
'role': instance.role,
'organisationId': instance.organisationId,
'organisationNom': instance.organisationNom,
'dateAdhesion': instance.dateAdhesion?.toIso8601String(),
'dateFinAdhesion': instance.dateFinAdhesion?.toIso8601String(),
'membreBureau': instance.membreBureau,
'responsable': instance.responsable,
'fonctionBureau': instance.fonctionBureau,
'numeroMembre': instance.numeroMembre,
'cotisationAJour': instance.cotisationAJour,
'nombreEvenementsParticipes': instance.nombreEvenementsParticipes,
'derniereActivite': instance.derniereActivite?.toIso8601String(),
'notes': instance.notes,
'dateCreation': instance.dateCreation?.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
'actif': instance.actif,
};
const _$GenreEnumMap = {
Genre.homme: 'HOMME',
Genre.femme: 'FEMME',
Genre.autre: 'AUTRE',
};
const _$StatutMembreEnumMap = {
StatutMembre.actif: 'ACTIF',
StatutMembre.inactif: 'INACTIF',
StatutMembre.suspendu: 'SUSPENDU',
StatutMembre.enAttente: 'EN_ATTENTE',
};

View File

@@ -0,0 +1,320 @@
/// Repository pour la gestion des membres
/// Interface avec l'API backend MembreResource
library membre_repository;
import 'package:dio/dio.dart';
import '../models/membre_complete_model.dart';
import '../../../../core/models/membre_search_result.dart';
import '../../../../core/models/membre_search_criteria.dart';
/// Interface du repository des membres
abstract class MembreRepository {
/// Récupère la liste des membres avec pagination
Future<MembreSearchResult> getMembres({
int page = 0,
int size = 20,
String? recherche,
});
/// Récupère un membre par son ID
Future<MembreCompletModel?> getMembreById(String id);
/// Crée un nouveau membre
Future<MembreCompletModel> createMembre(MembreCompletModel membre);
/// Met à jour un membre
Future<MembreCompletModel> updateMembre(String id, MembreCompletModel membre);
/// Supprime un membre
Future<void> deleteMembre(String id);
/// Active un membre
Future<MembreCompletModel> activateMembre(String id);
/// Désactive un membre
Future<MembreCompletModel> deactivateMembre(String id);
/// Recherche avancée de membres
Future<MembreSearchResult> searchMembres({
required MembreSearchCriteria criteria,
int page = 0,
int size = 20,
});
/// Récupère les membres actifs
Future<MembreSearchResult> getActiveMembers({int page = 0, int size = 20});
/// Récupère les membres du bureau
Future<MembreSearchResult> getBureauMembers({int page = 0, int size = 20});
/// Récupère les statistiques des membres
Future<Map<String, dynamic>> getMembresStats();
}
/// Implémentation du repository des membres
class MembreRepositoryImpl implements MembreRepository {
final Dio _dio;
static const String _baseUrl = '/api/membres';
MembreRepositoryImpl(this._dio);
@override
Future<MembreSearchResult> getMembres({
int page = 0,
int size = 20,
String? recherche,
}) async {
try {
// Si une recherche est fournie, utiliser l'endpoint de recherche
if (recherche?.isNotEmpty == true) {
final response = await _dio.get(
'$_baseUrl/recherche',
queryParameters: {
'q': recherche,
'page': page,
'size': size,
},
);
return _parseMembreSearchResult(response, page, size, MembreSearchCriteria(query: recherche));
}
// Sinon, récupérer tous les membres
final response = await _dio.get(
_baseUrl,
queryParameters: {
'page': page,
'size': size,
},
);
return _parseMembreSearchResult(response, page, size, const MembreSearchCriteria());
} on DioException catch (e) {
throw Exception('Erreur réseau lors de la récupération des membres: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la récupération des membres: $e');
}
}
/// Parse la réponse API et retourne un MembreSearchResult
/// Gère les deux formats possibles : List (simple) ou Map (paginé)
MembreSearchResult _parseMembreSearchResult(
Response response,
int page,
int size,
MembreSearchCriteria criteria,
) {
if (response.statusCode != 200) {
throw Exception('Erreur HTTP: ${response.statusCode}');
}
// Format simple : liste directe de membres
if (response.data is List) {
final List<dynamic> listData = response.data as List<dynamic>;
final membres = listData
.map((e) => MembreCompletModel.fromJson(e as Map<String, dynamic>))
.toList();
return MembreSearchResult(
membres: membres,
totalElements: membres.length,
totalPages: 1,
currentPage: page,
pageSize: membres.length,
numberOfElements: membres.length,
hasNext: false,
hasPrevious: false,
isFirst: true,
isLast: true,
criteria: criteria,
executionTimeMs: 0,
);
}
// Format paginé : objet avec métadonnées
return MembreSearchResult.fromJson(response.data as Map<String, dynamic>);
}
@override
Future<MembreCompletModel?> getMembreById(String id) async {
try {
final response = await _dio.get('$_baseUrl/$id');
if (response.statusCode == 200) {
return MembreCompletModel.fromJson(response.data as Map<String, dynamic>);
} else if (response.statusCode == 404) {
return null;
} else {
throw Exception('Erreur lors de la récupération du membre: ${response.statusCode}');
}
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
return null;
}
throw Exception('Erreur réseau lors de la récupération du membre: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la récupération du membre: $e');
}
}
@override
Future<MembreCompletModel> createMembre(MembreCompletModel membre) async {
try {
final response = await _dio.post(
_baseUrl,
data: membre.toJson(),
);
if (response.statusCode == 201 || response.statusCode == 200) {
return MembreCompletModel.fromJson(response.data as Map<String, dynamic>);
} else {
throw Exception('Erreur lors de la création du membre: ${response.statusCode}');
}
} on DioException catch (e) {
throw Exception('Erreur réseau lors de la création du membre: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la création du membre: $e');
}
}
@override
Future<MembreCompletModel> updateMembre(String id, MembreCompletModel membre) async {
try {
final response = await _dio.put(
'$_baseUrl/$id',
data: membre.toJson(),
);
if (response.statusCode == 200) {
return MembreCompletModel.fromJson(response.data as Map<String, dynamic>);
} else {
throw Exception('Erreur lors de la mise à jour du membre: ${response.statusCode}');
}
} on DioException catch (e) {
throw Exception('Erreur réseau lors de la mise à jour du membre: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la mise à jour du membre: $e');
}
}
@override
Future<void> deleteMembre(String id) async {
try {
final response = await _dio.delete('$_baseUrl/$id');
if (response.statusCode != 204 && response.statusCode != 200) {
throw Exception('Erreur lors de la suppression du membre: ${response.statusCode}');
}
} on DioException catch (e) {
throw Exception('Erreur réseau lors de la suppression du membre: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la suppression du membre: $e');
}
}
@override
Future<MembreCompletModel> activateMembre(String id) async {
try {
final response = await _dio.post('$_baseUrl/$id/activer');
if (response.statusCode == 200) {
return MembreCompletModel.fromJson(response.data as Map<String, dynamic>);
} else {
throw Exception('Erreur lors de l\'activation du membre: ${response.statusCode}');
}
} on DioException catch (e) {
throw Exception('Erreur réseau lors de l\'activation du membre: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de l\'activation du membre: $e');
}
}
@override
Future<MembreCompletModel> deactivateMembre(String id) async {
try {
final response = await _dio.post('$_baseUrl/$id/desactiver');
if (response.statusCode == 200) {
return MembreCompletModel.fromJson(response.data as Map<String, dynamic>);
} else {
throw Exception('Erreur lors de la désactivation du membre: ${response.statusCode}');
}
} on DioException catch (e) {
throw Exception('Erreur réseau lors de la désactivation du membre: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la désactivation du membre: $e');
}
}
@override
Future<MembreSearchResult> searchMembres({
required MembreSearchCriteria criteria,
int page = 0,
int size = 20,
}) async {
try {
// Les paramètres de pagination vont dans queryParameters
// Les critères de recherche vont directement dans le body
final response = await _dio.post(
'$_baseUrl/search/advanced',
queryParameters: {
'page': page,
'size': size,
},
data: criteria.toJson(),
);
return _parseMembreSearchResult(response, page, size, criteria);
} on DioException catch (e) {
throw Exception('Erreur réseau lors de la recherche de membres: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la recherche de membres: $e');
}
}
@override
Future<MembreSearchResult> getActiveMembers({int page = 0, int size = 20}) async {
// Utiliser la recherche avancée avec le critère statut=ACTIF
return searchMembres(
criteria: const MembreSearchCriteria(
statut: 'ACTIF',
includeInactifs: false,
),
page: page,
size: size,
);
}
@override
Future<MembreSearchResult> getBureauMembers({int page = 0, int size = 20}) async {
// Utiliser la recherche avancée avec le critère membreBureau=true
return searchMembres(
criteria: const MembreSearchCriteria(
membreBureau: true,
statut: 'ACTIF',
),
page: page,
size: size,
);
}
@override
Future<Map<String, dynamic>> getMembresStats() async {
try {
final response = await _dio.get('$_baseUrl/statistiques');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
} else {
throw Exception('Erreur lors de la récupération des statistiques: ${response.statusCode}');
}
} on DioException catch (e) {
throw Exception('Erreur réseau lors de la récupération des statistiques: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la récupération des statistiques: $e');
}
}
}

View File

@@ -270,7 +270,7 @@ class MembreSearchService {
if (criteria.dateAdhesionMin != null || criteria.dateAdhesionMax != null) complexityScore += 1;
// Temps de base + complexité
final baseTime = 100; // 100ms de base
const baseTime = 100; // 100ms de base
final additionalTime = complexityScore * 50; // 50ms par critère
return Duration(milliseconds: baseTime + additionalTime);

View File

@@ -0,0 +1,36 @@
/// Module de Dependency Injection pour les membres
library membres_di;
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../data/repositories/membre_repository_impl.dart';
import '../bloc/membres_bloc.dart';
/// Configuration de l'injection de dépendances pour le module Membres
class MembresDI {
static final GetIt _getIt = GetIt.instance;
/// Enregistre toutes les dépendances du module Membres
static void register() {
// Repository
_getIt.registerLazySingleton<MembreRepository>(
() => MembreRepositoryImpl(_getIt<Dio>()),
);
// BLoC - Factory pour créer une nouvelle instance à chaque fois
_getIt.registerFactory<MembresBloc>(
() => MembresBloc(_getIt<MembreRepository>()),
);
}
/// Désenregistre toutes les dépendances (pour les tests)
static void unregister() {
if (_getIt.isRegistered<MembresBloc>()) {
_getIt.unregister<MembresBloc>();
}
if (_getIt.isRegistered<MembreRepository>()) {
_getIt.unregister<MembreRepository>();
}
}
}

View File

@@ -1,10 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/models/membre_search_criteria.dart';
import '../../../../core/models/membre_search_result.dart';
import '../../../dashboard/presentation/widgets/dashboard_activity_tile.dart';
import '../widgets/membre_search_form.dart';
import '../widgets/membre_search_results.dart';
import '../widgets/search_statistics_card.dart';
@@ -37,8 +34,8 @@ class _AdvancedSearchPageState extends State<AdvancedSearchPage>
// Valeurs pour les filtres
String? _selectedStatut;
List<String> _selectedRoles = [];
List<String> _selectedOrganisations = [];
final List<String> _selectedRoles = [];
final List<String> _selectedOrganisations = [];
RangeValues _ageRange = const RangeValues(18, 65);
DateTimeRange? _adhesionDateRange;
bool _includeInactifs = false;

View File

@@ -0,0 +1,961 @@
/// Page des membres avec données injectées depuis le BLoC
///
/// Cette version de MembersPage accepte les données en paramètre
/// au lieu d'utiliser des données mock hardcodées.
library members_page_connected;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/auth/bloc/auth_bloc.dart';
import '../../../../core/auth/models/user_role.dart';
import '../../../../core/utils/logger.dart';
import '../widgets/add_member_dialog.dart';
import '../../bloc/membres_bloc.dart';
import '../../bloc/membres_event.dart';
/// Page de gestion des membres avec données injectées
class MembersPageWithData extends StatefulWidget {
/// Liste des membres à afficher
final List<Map<String, dynamic>> members;
/// Nombre total de membres (pour la pagination)
final int totalCount;
/// Page actuelle
final int currentPage;
/// Nombre total de pages
final int totalPages;
/// Taille de la page
final int pageSize;
const MembersPageWithData({
super.key,
required this.members,
required this.totalCount,
required this.currentPage,
required this.totalPages,
this.pageSize = 20,
});
@override
State<MembersPageWithData> createState() => _MembersPageWithDataState();
}
class _MembersPageWithDataState extends State<MembersPageWithData>
with TickerProviderStateMixin {
// Controllers et état
final TextEditingController _searchController = TextEditingController();
late TabController _tabController;
// État de l'interface
String _searchQuery = '';
String _selectedFilter = 'Tous';
final String _selectedSort = 'Nom';
bool _isGridView = false;
bool _showAdvancedFilters = false;
// Filtres avancés
final List<String> _selectedRoles = [];
List<String> _selectedStatuses = ['Actif', 'Inactif', 'Suspendu', 'En attente'];
DateTimeRange? _dateRange;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
AppLogger.info('MembersPageWithData initialisée avec ${widget.members.length} membres');
}
@override
void dispose() {
_searchController.dispose();
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is! AuthAuthenticated) {
return Container(
color: const Color(0xFFF8F9FA),
child: const Center(child: CircularProgressIndicator()),
);
}
return Container(
color: const Color(0xFFF8F9FA),
child: _buildMembersContent(state),
);
},
);
}
/// Contenu principal de la page membres
Widget _buildMembersContent(AuthAuthenticated state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header avec titre et actions
_buildMembersHeader(state),
const SizedBox(height: 16),
// Statistiques et métriques
_buildMembersMetrics(),
const SizedBox(height: 16),
// Barre de recherche et filtres
_buildSearchAndFilters(),
const SizedBox(height: 16),
// Onglets de catégories
_buildCategoryTabs(),
const SizedBox(height: 16),
// Liste/Grille des membres
_buildMembersDisplay(),
// Pagination
if (widget.totalPages > 1) ...[
const SizedBox(height: 16),
_buildPagination(),
],
],
),
);
}
/// Header avec titre et actions principales
Widget _buildMembersHeader(AuthAuthenticated state) {
final canManageMembers = _canManageMembers(state.effectiveRole);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF6C5CE7).withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.people,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Gestion des Membres',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${widget.totalCount} membres au total',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 14,
),
),
],
),
),
if (canManageMembers) ...[
IconButton(
icon: const Icon(Icons.add_circle, color: Colors.white, size: 28),
onPressed: () {
AppLogger.userAction('Add new member button clicked');
_showAddMemberDialog();
},
tooltip: 'Ajouter un membre',
),
IconButton(
icon: const Icon(Icons.file_download, color: Colors.white),
onPressed: () {
AppLogger.userAction('Export members button clicked');
_exportMembers();
},
tooltip: 'Exporter',
),
],
],
),
],
),
);
}
/// Métriques et statistiques des membres
Widget _buildMembersMetrics() {
final filteredMembers = _getFilteredMembers();
final activeMembers = filteredMembers.where((m) => m['status'] == 'Actif').length;
final inactiveMembers = filteredMembers.where((m) => m['status'] == 'Inactif').length;
final pendingMembers = filteredMembers.where((m) => m['status'] == 'En attente').length;
return Row(
children: [
Expanded(
child: _buildMetricCard(
'Actifs',
activeMembers.toString(),
Icons.check_circle,
const Color(0xFF00B894),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
'Inactifs',
inactiveMembers.toString(),
Icons.pause_circle,
const Color(0xFFFFBE76),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard(
'En attente',
pendingMembers.toString(),
Icons.pending,
const Color(0xFF74B9FF),
),
),
],
);
}
/// Carte de métrique individuelle
Widget _buildMetricCard(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF636E72),
),
),
],
),
);
}
/// Barre de recherche et filtres
Widget _buildSearchAndFilters() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher un membre...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
filled: true,
fillColor: const Color(0xFFF8F9FA),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
AppLogger.userAction('Search members', data: {'query': value});
},
),
),
const SizedBox(width: 12),
IconButton(
icon: Icon(
_isGridView ? Icons.view_list : Icons.grid_view,
color: const Color(0xFF6C5CE7),
),
onPressed: () {
setState(() {
_isGridView = !_isGridView;
});
AppLogger.userAction('Toggle view mode', data: {'isGrid': _isGridView});
},
tooltip: _isGridView ? 'Vue liste' : 'Vue grille',
),
],
),
],
),
);
}
/// Onglets de catégories
Widget _buildCategoryTabs() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
controller: _tabController,
labelColor: const Color(0xFF6C5CE7),
unselectedLabelColor: const Color(0xFF636E72),
indicatorColor: const Color(0xFF6C5CE7),
tabs: const [
Tab(text: 'Tous'),
Tab(text: 'Actifs'),
Tab(text: 'Équipes'),
Tab(text: 'Analytics'),
],
),
);
}
/// Affichage principal des membres
Widget _buildMembersDisplay() {
final filteredMembers = _getFilteredMembers();
if (filteredMembers.isEmpty) {
return _buildEmptyState();
}
return SizedBox(
height: 600,
child: TabBarView(
controller: _tabController,
children: [
_buildMembersList(filteredMembers),
_buildMembersList(filteredMembers.where((m) => m['status'] == 'Actif').toList()),
_buildTeamsView(filteredMembers),
_buildAnalyticsView(filteredMembers),
],
),
);
}
/// Liste des membres
Widget _buildMembersList(List<Map<String, dynamic>> members) {
if (_isGridView) {
return _buildMembersGrid(members);
}
return ListView.builder(
itemCount: members.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final member = members[index];
return _buildMemberCard(member);
},
);
}
/// Carte d'un membre
Widget _buildMemberCard(Map<String, dynamic> member) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF6C5CE7),
child: Text(
_getInitials(member['name']),
style: const TextStyle(color: Colors.white),
),
),
title: Text(
member['name'],
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(member['email']),
trailing: _buildStatusChip(member['status']),
onTap: () {
AppLogger.userAction('View member details', data: {'memberId': member['id']});
_showMemberDetails(member);
},
),
);
}
/// Grille des membres
Widget _buildMembersGrid(List<Map<String, dynamic>> members) {
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.85,
),
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
return _buildMemberGridCard(member);
},
);
}
/// Carte membre pour la grille
Widget _buildMemberGridCard(Map<String, dynamic> member) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: () {
AppLogger.userAction('View member details (grid)', data: {'memberId': member['id']});
_showMemberDetails(member);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 30,
backgroundColor: const Color(0xFF6C5CE7),
child: Text(
_getInitials(member['name']),
style: const TextStyle(color: Colors.white, fontSize: 20),
),
),
const SizedBox(height: 12),
Text(
member['name'],
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
member['role'],
style: const TextStyle(
fontSize: 12,
color: Color(0xFF636E72),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
_buildStatusChip(member['status']),
],
),
),
),
);
}
/// Chip de statut
Widget _buildStatusChip(String status) {
Color color;
switch (status) {
case 'Actif':
color = const Color(0xFF00B894);
break;
case 'Inactif':
color = const Color(0xFFFFBE76);
break;
case 'Suspendu':
color = const Color(0xFFFF7675);
break;
case 'En attente':
color = const Color(0xFF74B9FF);
break;
default:
color = const Color(0xFF636E72);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
status,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
/// Vue des équipes (placeholder)
Widget _buildTeamsView(List<Map<String, dynamic>> members) {
return const Center(
child: Text('Vue des équipes - À implémenter'),
);
}
/// Vue analytics (placeholder)
Widget _buildAnalyticsView(List<Map<String, dynamic>> members) {
return const Center(
child: Text('Vue analytics - À implémenter'),
);
}
/// État vide
Widget _buildEmptyState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: Color(0xFF636E72)),
SizedBox(height: 16),
Text(
'Aucun membre trouvé',
style: TextStyle(fontSize: 18, color: Color(0xFF636E72)),
),
],
),
);
}
/// Pagination
Widget _buildPagination() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: widget.currentPage > 0
? () {
AppLogger.userAction('Previous page', data: {'page': widget.currentPage - 1});
context.read<MembresBloc>().add(LoadMembres(
page: widget.currentPage - 1,
size: widget.pageSize,
));
}
: null,
),
Text('Page ${widget.currentPage + 1} / ${widget.totalPages}'),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: widget.currentPage < widget.totalPages - 1
? () {
AppLogger.userAction('Next page', data: {'page': widget.currentPage + 1});
context.read<MembresBloc>().add(LoadMembres(
page: widget.currentPage + 1,
size: widget.pageSize,
));
}
: null,
),
],
),
);
}
/// Obtenir les membres filtrés
List<Map<String, dynamic>> _getFilteredMembers() {
var filtered = widget.members;
// Filtrer par recherche
if (_searchQuery.isNotEmpty) {
filtered = filtered.where((m) {
final name = m['name'].toString().toLowerCase();
final email = m['email'].toString().toLowerCase();
final query = _searchQuery.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
}
// Filtrer par statut
if (_selectedStatuses.isNotEmpty) {
filtered = filtered.where((m) => _selectedStatuses.contains(m['status'])).toList();
}
return filtered;
}
/// Obtenir les initiales d'un nom
String _getInitials(String name) {
final parts = name.split(' ');
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.substring(0, 1).toUpperCase();
}
/// Vérifier si l'utilisateur peut gérer les membres
bool _canManageMembers(UserRole role) {
return role.level >= UserRole.moderator.level;
}
/// Afficher les détails d'un membre
void _showMemberDetails(Map<String, dynamic> member) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(member['name']),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Email: ${member['email']}'),
Text('Rôle: ${member['role']}'),
Text('Statut: ${member['status']}'),
if (member['phone'] != null) Text('Téléphone: ${member['phone']}'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
/// Afficher le dialogue d'ajout de membre
void _showAddMemberDialog() {
showDialog(
context: context,
builder: (context) => BlocProvider.value(
value: context.read<MembresBloc>(),
child: const AddMemberDialog(),
),
);
}
/// Exporter les membres
void _exportMembers() {
// TODO: Implémenter l'export des membres
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Export des membres en cours...'),
backgroundColor: Colors.blue,
),
);
}
}
/// Version améliorée de MembersPageWithData avec support de la pagination
class MembersPageWithDataAndPagination extends StatefulWidget {
final List<Map<String, dynamic>> members;
final int totalCount;
final int currentPage;
final int totalPages;
final Function(int page) onPageChanged;
final VoidCallback onRefresh;
const MembersPageWithDataAndPagination({
super.key,
required this.members,
required this.totalCount,
required this.currentPage,
required this.totalPages,
required this.onPageChanged,
required this.onRefresh,
});
@override
State<MembersPageWithDataAndPagination> createState() => _MembersPageWithDataAndPaginationState();
}
class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAndPagination> {
final TextEditingController _searchController = TextEditingController();
late TabController _tabController;
String _searchQuery = '';
String _selectedFilter = 'Tous';
bool _isGridView = false;
final List<String> _selectedRoles = [];
List<String> _selectedStatuses = ['Actif', 'Inactif', 'Suspendu', 'En attente'];
@override
void initState() {
super.initState();
// Note: TabController nécessite un TickerProvider, on utilise un simple state sans mixin pour l'instant
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async {
widget.onRefresh();
// Attendre un peu pour l'animation
await Future.delayed(const Duration(milliseconds: 500));
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 16),
_buildMetrics(),
const SizedBox(height: 16),
_buildMembersList(),
if (widget.totalPages > 1) ...[
const SizedBox(height: 16),
_buildPagination(),
],
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Icon(Icons.people, color: Colors.white, size: 28),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Gestion des Membres',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
'${widget.totalCount} membres au total',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
],
),
);
}
Widget _buildMetrics() {
final activeCount = widget.members.where((m) => m['status'] == 'Actif').length;
final inactiveCount = widget.members.where((m) => m['status'] == 'Inactif').length;
return Row(
children: [
Expanded(
child: _buildMetricCard('Actifs', activeCount.toString(), Icons.check_circle, const Color(0xFF00B894)),
),
const SizedBox(width: 12),
Expanded(
child: _buildMetricCard('Inactifs', inactiveCount.toString(), Icons.pause_circle, const Color(0xFFFFBE76)),
),
],
);
}
Widget _buildMetricCard(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(value, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color)),
Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF636E72))),
],
),
);
}
Widget _buildMembersList() {
if (widget.members.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: Text('Aucun membre trouvé'),
),
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.members.length,
itemBuilder: (context, index) {
final member = widget.members[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF6C5CE7),
child: Text(
_getInitials(member['name']),
style: const TextStyle(color: Colors.white),
),
),
title: Text(member['name']),
subtitle: Text(member['email']),
trailing: _buildStatusChip(member['status']),
),
);
},
);
}
Widget _buildStatusChip(String status) {
Color color;
switch (status) {
case 'Actif':
color = const Color(0xFF00B894);
break;
case 'Inactif':
color = const Color(0xFFFFBE76);
break;
default:
color = const Color(0xFF636E72);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
status,
style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w500),
),
);
}
Widget _buildPagination() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: widget.currentPage > 0
? () => widget.onPageChanged(widget.currentPage - 1)
: null,
),
Text('Page ${widget.currentPage + 1} / ${widget.totalPages}'),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: widget.currentPage < widget.totalPages - 1
? () => widget.onPageChanged(widget.currentPage + 1)
: null,
),
],
),
);
}
String _getInitials(String name) {
final parts = name.split(' ');
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.substring(0, 1).toUpperCase();
}
}

View File

@@ -0,0 +1,267 @@
/// Wrapper BLoC pour la page des membres
///
/// Ce fichier enveloppe la MembersPage existante avec le MembresBloc
/// pour connecter l'UI riche existante à l'API backend réelle.
library members_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/widgets/error_widget.dart';
import '../../../../core/widgets/loading_widget.dart';
import '../../../../core/utils/logger.dart';
import '../../bloc/membres_bloc.dart';
import '../../bloc/membres_event.dart';
import '../../bloc/membres_state.dart';
import '../../data/models/membre_complete_model.dart';
import 'members_page_connected.dart';
final _getIt = GetIt.instance;
/// Wrapper qui fournit le BLoC à la page des membres
class MembersPageWrapper extends StatelessWidget {
const MembersPageWrapper({super.key});
@override
Widget build(BuildContext context) {
AppLogger.info('MembersPageWrapper: Création du BlocProvider');
return BlocProvider<MembresBloc>(
create: (context) {
AppLogger.info('MembresPageWrapper: Initialisation du MembresBloc');
final bloc = _getIt<MembresBloc>();
// Charger les membres au démarrage
bloc.add(const LoadMembres());
return bloc;
},
child: const MembersPageConnected(),
);
}
}
/// Page des membres connectée au BLoC
///
/// Cette page gère les états du BLoC et affiche l'UI appropriée
class MembersPageConnected extends StatelessWidget {
const MembersPageConnected({super.key});
@override
Widget build(BuildContext context) {
return BlocListener<MembresBloc, MembresState>(
listener: (context, state) {
// Gestion des erreurs avec SnackBar
if (state is MembresError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: () {
context.read<MembresBloc>().add(const LoadMembres());
},
),
),
);
}
// Message de succès après création
if (state is MembresLoaded && state.membres.isNotEmpty) {
// Note: On pourrait ajouter un flag dans le state pour savoir si c'est après une création
// Pour l'instant, on ne fait rien ici
}
},
child: BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
AppLogger.blocState('MembresBloc', state.runtimeType.toString());
// État initial
if (state is MembresInitial) {
return Container(
color: const Color(0xFFF8F9FA),
child: const Center(
child: AppLoadingWidget(message: 'Initialisation...'),
),
);
}
// État de chargement
if (state is MembresLoading) {
return Container(
color: const Color(0xFFF8F9FA),
child: const Center(
child: AppLoadingWidget(message: 'Chargement des membres...'),
),
);
}
// État de rafraîchissement (afficher l'UI avec un indicateur)
if (state is MembresRefreshing) {
// TODO: Afficher l'UI avec un indicateur de rafraîchissement
return Container(
color: const Color(0xFFF8F9FA),
child: const Center(
child: AppLoadingWidget(message: 'Actualisation...'),
),
);
}
// État chargé avec succès
if (state is MembresLoaded) {
final membres = state.membres;
AppLogger.info('MembresPageConnected: ${membres.length} membres chargés');
// Convertir les membres en format Map pour l'UI existante
final membersData = _convertMembersToMapList(membres);
return MembersPageWithDataAndPagination(
members: membersData,
totalCount: state.totalElements,
currentPage: state.currentPage,
totalPages: state.totalPages,
onPageChanged: (newPage) {
AppLogger.userAction('Load page', data: {'page': newPage});
context.read<MembresBloc>().add(LoadMembres(page: newPage));
},
onRefresh: () {
AppLogger.userAction('Refresh membres');
context.read<MembresBloc>().add(const LoadMembres(refresh: true));
},
);
}
// État d'erreur réseau
if (state is MembresNetworkError) {
AppLogger.error('MembersPageConnected: Erreur réseau', error: state.message);
return Container(
color: const Color(0xFFF8F9FA),
child: NetworkErrorWidget(
onRetry: () {
AppLogger.userAction('Retry load membres after network error');
context.read<MembresBloc>().add(const LoadMembres());
},
),
);
}
// État d'erreur générale
if (state is MembresError) {
AppLogger.error('MembersPageConnected: Erreur', error: state.message);
return Container(
color: const Color(0xFFF8F9FA),
child: AppErrorWidget(
message: state.message,
onRetry: () {
AppLogger.userAction('Retry load membres after error');
context.read<MembresBloc>().add(const LoadMembres());
},
),
);
}
// État par défaut (ne devrait jamais arriver)
AppLogger.warning('MembersPageConnected: État non géré: ${state.runtimeType}');
return Container(
color: const Color(0xFFF8F9FA),
child: const Center(
child: AppLoadingWidget(message: 'Chargement...'),
),
);
},
),
);
}
/// Convertit une liste de MembreCompletModel en List<Map<String, dynamic>>
/// pour compatibilité avec l'UI existante
List<Map<String, dynamic>> _convertMembersToMapList(List<MembreCompletModel> membres) {
return membres.map((membre) => _convertMembreToMap(membre)).toList();
}
/// Convertit un MembreCompletModel en Map<String, dynamic>
Map<String, dynamic> _convertMembreToMap(MembreCompletModel membre) {
return {
'id': membre.id ?? '',
'name': membre.nomComplet,
'email': membre.email,
'role': _mapRoleToString(membre.role),
'status': _mapStatutToString(membre.statut),
'joinDate': membre.dateAdhesion,
'lastActivity': DateTime.now(), // TODO: Ajouter ce champ au modèle
'avatar': membre.photo,
'phone': membre.telephone ?? '',
'department': membre.profession ?? '',
'location': '${membre.ville ?? ''}, ${membre.pays ?? ''}',
'permissions': 15, // TODO: Calculer depuis les permissions réelles
'contributionScore': 0, // TODO: Ajouter ce champ au modèle
'eventsAttended': 0, // TODO: Ajouter ce champ au modèle
'projectsInvolved': 0, // TODO: Ajouter ce champ au modèle
// Champs supplémentaires du modèle
'prenom': membre.prenom,
'nom': membre.nom,
'dateNaissance': membre.dateNaissance,
'genre': membre.genre?.name,
'adresse': membre.adresse,
'ville': membre.ville,
'codePostal': membre.codePostal,
'region': membre.region,
'pays': membre.pays,
'profession': membre.profession,
'nationalite': membre.nationalite,
'organisationId': membre.organisationId,
'membreBureau': membre.membreBureau,
'responsable': membre.responsable,
'fonctionBureau': membre.fonctionBureau,
'numeroMembre': membre.numeroMembre,
'cotisationAJour': membre.cotisationAJour,
// Propriétés calculées
'initiales': membre.initiales,
'age': membre.age,
'estActifEtAJour': membre.estActifEtAJour,
};
}
/// Mappe le rôle du modèle vers une chaîne lisible
String _mapRoleToString(String? role) {
if (role == null) return 'Membre Simple';
switch (role.toLowerCase()) {
case 'superadmin':
return 'Super Administrateur';
case 'orgadmin':
return 'Administrateur Org';
case 'moderator':
return 'Modérateur';
case 'activemember':
return 'Membre Actif';
case 'simplemember':
return 'Membre Simple';
case 'visitor':
return 'Visiteur';
default:
return role;
}
}
/// Mappe le statut du modèle vers une chaîne lisible
String _mapStatutToString(StatutMembre? statut) {
if (statut == null) return 'Actif';
switch (statut) {
case StatutMembre.actif:
return 'Actif';
case StatutMembre.inactif:
return 'Inactif';
case StatutMembre.suspendu:
return 'Suspendu';
case StatutMembre.enAttente:
return 'En attente';
}
}
}

View File

@@ -0,0 +1,403 @@
/// Dialogue d'ajout de membre
/// Formulaire complet pour créer un nouveau membre
library add_member_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../bloc/membres_bloc.dart';
import '../../bloc/membres_event.dart';
import '../../data/models/membre_complete_model.dart';
/// Dialogue d'ajout de membre
class AddMemberDialog extends StatefulWidget {
const AddMemberDialog({super.key});
@override
State<AddMemberDialog> createState() => _AddMemberDialogState();
}
class _AddMemberDialogState extends State<AddMemberDialog> {
final _formKey = GlobalKey<FormState>();
// Contrôleurs de texte
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _telephoneController = TextEditingController();
final _adresseController = TextEditingController();
final _villeController = TextEditingController();
final _codePostalController = TextEditingController();
final _regionController = TextEditingController();
final _paysController = TextEditingController();
final _professionController = TextEditingController();
final _nationaliteController = TextEditingController();
// Valeurs sélectionnées
Genre? _selectedGenre;
DateTime? _dateNaissance;
StatutMembre _selectedStatut = StatutMembre.actif;
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_adresseController.dispose();
_villeController.dispose();
_codePostalController.dispose();
_regionController.dispose();
_paysController.dispose();
_professionController.dispose();
_nationaliteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
constraints: const BoxConstraints(maxHeight: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFF6C5CE7),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
child: Row(
children: [
const Icon(Icons.person_add, color: Colors.white),
const SizedBox(width: 12),
const Text(
'Ajouter un membre',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
],
),
),
// Formulaire
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informations personnelles
_buildSectionTitle('Informations personnelles'),
const SizedBox(height: 12),
TextFormField(
controller: _nomController,
decoration: const InputDecoration(
labelText: 'Nom *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le nom est obligatoire';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _prenomController,
decoration: const InputDecoration(
labelText: 'Prénom *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le prénom est obligatoire';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'L\'email est obligatoire';
}
if (!value.contains('@')) {
return 'Email invalide';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _telephoneController,
decoration: const InputDecoration(
labelText: 'Téléphone',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
// Genre
DropdownButtonFormField<Genre>(
value: _selectedGenre,
decoration: const InputDecoration(
labelText: 'Genre',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.wc),
),
items: Genre.values.map((genre) {
return DropdownMenuItem(
value: genre,
child: Text(_getGenreLabel(genre)),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedGenre = value;
});
},
),
const SizedBox(height: 12),
// Date de naissance
InkWell(
onTap: () => _selectDate(context),
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date de naissance',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.calendar_today),
),
child: Text(
_dateNaissance != null
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
: 'Sélectionner une date',
),
),
),
const SizedBox(height: 16),
// Adresse
_buildSectionTitle('Adresse'),
const SizedBox(height: 12),
TextFormField(
controller: _adresseController,
decoration: const InputDecoration(
labelText: 'Adresse',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.home),
),
maxLines: 2,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _villeController,
decoration: const InputDecoration(
labelText: 'Ville',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _codePostalController,
decoration: const InputDecoration(
labelText: 'Code postal',
border: OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _regionController,
decoration: const InputDecoration(
labelText: 'Région',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _paysController,
decoration: const InputDecoration(
labelText: 'Pays',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Informations professionnelles
_buildSectionTitle('Informations professionnelles'),
const SizedBox(height: 12),
TextFormField(
controller: _professionController,
decoration: const InputDecoration(
labelText: 'Profession',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.work),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _nationaliteController,
decoration: const InputDecoration(
labelText: 'Nationalité',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.flag),
),
),
],
),
),
),
),
// Boutons d'action
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
border: Border(top: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
),
child: const Text('Créer le membre'),
),
],
),
),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
);
}
String _getGenreLabel(Genre genre) {
switch (genre) {
case Genre.homme:
return 'Homme';
case Genre.femme:
return 'Femme';
case Genre.autre:
return 'Autre';
}
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (picked != null && picked != _dateNaissance) {
setState(() {
_dateNaissance = picked;
});
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
// Créer le modèle de membre
final membre = MembreCompletModel(
nom: _nomController.text,
prenom: _prenomController.text,
email: _emailController.text,
telephone: _telephoneController.text.isNotEmpty ? _telephoneController.text : null,
dateNaissance: _dateNaissance,
genre: _selectedGenre,
adresse: _adresseController.text.isNotEmpty ? _adresseController.text : null,
ville: _villeController.text.isNotEmpty ? _villeController.text : null,
codePostal: _codePostalController.text.isNotEmpty ? _codePostalController.text : null,
region: _regionController.text.isNotEmpty ? _regionController.text : null,
pays: _paysController.text.isNotEmpty ? _paysController.text : null,
profession: _professionController.text.isNotEmpty ? _professionController.text : null,
nationalite: _nationaliteController.text.isNotEmpty ? _nationaliteController.text : null,
statut: _selectedStatut,
);
// Envoyer l'événement au BLoC
context.read<MembresBloc>().add(CreateMembre(membre));
// Fermer le dialogue
Navigator.pop(context);
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre créé avec succès'),
backgroundColor: Colors.green,
),
);
}
}
}

View File

@@ -0,0 +1,441 @@
/// Dialogue de modification de membre
/// Formulaire complet pour modifier un membre existant
library edit_member_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../bloc/membres_bloc.dart';
import '../../bloc/membres_event.dart';
import '../../data/models/membre_complete_model.dart';
/// Dialogue de modification de membre
class EditMemberDialog extends StatefulWidget {
final MembreCompletModel membre;
const EditMemberDialog({
super.key,
required this.membre,
});
@override
State<EditMemberDialog> createState() => _EditMemberDialogState();
}
class _EditMemberDialogState extends State<EditMemberDialog> {
final _formKey = GlobalKey<FormState>();
// Contrôleurs de texte
late final TextEditingController _nomController;
late final TextEditingController _prenomController;
late final TextEditingController _emailController;
late final TextEditingController _telephoneController;
late final TextEditingController _adresseController;
late final TextEditingController _villeController;
late final TextEditingController _codePostalController;
late final TextEditingController _regionController;
late final TextEditingController _paysController;
late final TextEditingController _professionController;
late final TextEditingController _nationaliteController;
// Valeurs sélectionnées
Genre? _selectedGenre;
DateTime? _dateNaissance;
StatutMembre? _selectedStatut;
@override
void initState() {
super.initState();
// Initialiser les contrôleurs avec les valeurs existantes
_nomController = TextEditingController(text: widget.membre.nom);
_prenomController = TextEditingController(text: widget.membre.prenom);
_emailController = TextEditingController(text: widget.membre.email);
_telephoneController = TextEditingController(text: widget.membre.telephone ?? '');
_adresseController = TextEditingController(text: widget.membre.adresse ?? '');
_villeController = TextEditingController(text: widget.membre.ville ?? '');
_codePostalController = TextEditingController(text: widget.membre.codePostal ?? '');
_regionController = TextEditingController(text: widget.membre.region ?? '');
_paysController = TextEditingController(text: widget.membre.pays ?? '');
_professionController = TextEditingController(text: widget.membre.profession ?? '');
_nationaliteController = TextEditingController(text: widget.membre.nationalite ?? '');
_selectedGenre = widget.membre.genre;
_dateNaissance = widget.membre.dateNaissance;
_selectedStatut = widget.membre.statut;
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_adresseController.dispose();
_villeController.dispose();
_codePostalController.dispose();
_regionController.dispose();
_paysController.dispose();
_professionController.dispose();
_nationaliteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
constraints: const BoxConstraints(maxHeight: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFF6C5CE7),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
child: Row(
children: [
const Icon(Icons.edit, color: Colors.white),
const SizedBox(width: 12),
const Text(
'Modifier le membre',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
],
),
),
// Formulaire
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informations personnelles
_buildSectionTitle('Informations personnelles'),
const SizedBox(height: 12),
TextFormField(
controller: _nomController,
decoration: const InputDecoration(
labelText: 'Nom *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le nom est obligatoire';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _prenomController,
decoration: const InputDecoration(
labelText: 'Prénom *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le prénom est obligatoire';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'L\'email est obligatoire';
}
if (!value.contains('@')) {
return 'Email invalide';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _telephoneController,
decoration: const InputDecoration(
labelText: 'Téléphone',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
// Genre
DropdownButtonFormField<Genre>(
value: _selectedGenre,
decoration: const InputDecoration(
labelText: 'Genre',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.wc),
),
items: Genre.values.map((genre) {
return DropdownMenuItem(
value: genre,
child: Text(_getGenreLabel(genre)),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedGenre = value;
});
},
),
const SizedBox(height: 12),
// Statut
DropdownButtonFormField<StatutMembre>(
value: _selectedStatut,
decoration: const InputDecoration(
labelText: 'Statut *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.info),
),
items: StatutMembre.values.map((statut) {
return DropdownMenuItem(
value: statut,
child: Text(_getStatutLabel(statut)),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedStatut = value;
});
},
),
const SizedBox(height: 12),
// Date de naissance
InkWell(
onTap: () => _selectDate(context),
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date de naissance',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.calendar_today),
),
child: Text(
_dateNaissance != null
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
: 'Sélectionner une date',
),
),
),
const SizedBox(height: 16),
// Adresse
_buildSectionTitle('Adresse'),
const SizedBox(height: 12),
TextFormField(
controller: _adresseController,
decoration: const InputDecoration(
labelText: 'Adresse',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.home),
),
maxLines: 2,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _villeController,
decoration: const InputDecoration(
labelText: 'Ville',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _codePostalController,
decoration: const InputDecoration(
labelText: 'Code postal',
border: OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _regionController,
decoration: const InputDecoration(
labelText: 'Région',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _paysController,
decoration: const InputDecoration(
labelText: 'Pays',
border: OutlineInputBorder(),
),
),
],
),
),
),
),
// Boutons d'action
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
border: Border(top: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
),
child: const Text('Enregistrer'),
),
],
),
),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
);
}
String _getGenreLabel(Genre genre) {
switch (genre) {
case Genre.homme:
return 'Homme';
case Genre.femme:
return 'Femme';
case Genre.autre:
return 'Autre';
}
}
String _getStatutLabel(StatutMembre statut) {
switch (statut) {
case StatutMembre.actif:
return 'Actif';
case StatutMembre.inactif:
return 'Inactif';
case StatutMembre.suspendu:
return 'Suspendu';
case StatutMembre.enAttente:
return 'En attente';
}
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (picked != null && picked != _dateNaissance) {
setState(() {
_dateNaissance = picked;
});
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
// Créer le modèle de membre mis à jour
final membreUpdated = widget.membre.copyWith(
nom: _nomController.text,
prenom: _prenomController.text,
email: _emailController.text,
telephone: _telephoneController.text.isNotEmpty ? _telephoneController.text : null,
dateNaissance: _dateNaissance,
genre: _selectedGenre,
adresse: _adresseController.text.isNotEmpty ? _adresseController.text : null,
ville: _villeController.text.isNotEmpty ? _villeController.text : null,
codePostal: _codePostalController.text.isNotEmpty ? _codePostalController.text : null,
region: _regionController.text.isNotEmpty ? _regionController.text : null,
pays: _paysController.text.isNotEmpty ? _paysController.text : null,
profession: _professionController.text.isNotEmpty ? _professionController.text : null,
nationalite: _nationaliteController.text.isNotEmpty ? _nationaliteController.text : null,
statut: _selectedStatut!,
);
// Envoyer l'événement au BLoC
context.read<MembresBloc>().add(UpdateMembre(widget.membre.id!, membreUpdated));
// Fermer le dialogue
Navigator.pop(context);
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre modifié avec succès'),
backgroundColor: Colors.green,
),
);
}
}
}

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import '../../../../core/models/membre_search_result.dart' as search_model;
import '../../data/models/membre_model.dart' as member_model;
import '../../data/models/membre_complete_model.dart';
/// Widget d'affichage des résultats de recherche de membres
/// Gère la pagination, le tri et l'affichage des membres trouvés
class MembreSearchResults extends StatefulWidget {
final search_model.MembreSearchResult result;
final Function(member_model.MembreModel)? onMembreSelected;
final Function(MembreCompletModel)? onMembreSelected;
final bool showPagination;
const MembreSearchResults({
@@ -151,12 +151,12 @@ class _MembreSearchResultsState extends State<MembreSearchResults> {
}
/// Carte d'affichage d'un membre
Widget _buildMembreCard(member_model.MembreModel membre, int index) {
Widget _buildMembreCard(MembreCompletModel membre, int index) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getStatusColor(membre.statut ?? 'ACTIF'),
backgroundColor: _getStatusColor(membre.statut),
child: Text(
_getInitials(membre.nom, membre.prenom),
style: const TextStyle(
@@ -197,14 +197,14 @@ class _MembreSearchResultsState extends State<MembreSearchResults> {
),
],
),
if (membre.organisation?.nom?.isNotEmpty == true)
if (membre.organisationNom?.isNotEmpty == true)
Row(
children: [
const Icon(Icons.business, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
membre.organisation!.nom!,
membre.organisationNom!,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
@@ -216,7 +216,7 @@ class _MembreSearchResultsState extends State<MembreSearchResults> {
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatusChip(membre.statut ?? 'ACTIF'),
_buildStatusChip(membre.statut),
if (membre.role?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
@@ -296,7 +296,7 @@ class _MembreSearchResultsState extends State<MembreSearchResults> {
}
/// Chip de statut
Widget _buildStatusChip(String statut) {
Widget _buildStatusChip(StatutMembre statut) {
final color = _getStatusColor(statut);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
@@ -317,15 +317,15 @@ class _MembreSearchResultsState extends State<MembreSearchResults> {
}
/// Obtient la couleur du statut
Color _getStatusColor(String statut) {
switch (statut.toUpperCase()) {
case 'ACTIF':
Color _getStatusColor(StatutMembre statut) {
switch (statut) {
case StatutMembre.actif:
return Colors.green;
case 'INACTIF':
case StatutMembre.inactif:
return Colors.orange;
case 'SUSPENDU':
case StatutMembre.suspendu:
return Colors.red;
case 'RADIE':
case StatutMembre.enAttente:
return Colors.grey;
default:
return Colors.grey;
@@ -333,18 +333,16 @@ class _MembreSearchResultsState extends State<MembreSearchResults> {
}
/// Obtient le libellé du statut
String _getStatusLabel(String statut) {
switch (statut.toUpperCase()) {
case 'ACTIF':
String _getStatusLabel(StatutMembre statut) {
switch (statut) {
case StatutMembre.actif:
return 'Actif';
case 'INACTIF':
case StatutMembre.inactif:
return 'Inactif';
case 'SUSPENDU':
case StatutMembre.suspendu:
return 'Suspendu';
case 'RADIE':
return 'Radié';
default:
return statut;
case StatutMembre.enAttente:
return 'En attente';
}
}