Refactoring - Version OK

This commit is contained in:
dahoud
2025-11-17 16:02:04 +00:00
parent 3f00a26308
commit 3b9ffac8cd
198 changed files with 18010 additions and 11383 deletions

View File

@@ -0,0 +1,488 @@
/// BLoC pour la gestion des organisations
library organizations_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/models/organization_model.dart';
import '../data/services/organization_service.dart';
import 'organizations_event.dart';
import 'organizations_state.dart';
/// BLoC principal pour la gestion des organisations
class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
final OrganizationService _organizationService;
OrganizationsBloc(this._organizationService) : super(const OrganizationsInitial()) {
// Enregistrement des handlers d'événements
on<LoadOrganizations>(_onLoadOrganizations);
on<LoadMoreOrganizations>(_onLoadMoreOrganizations);
on<SearchOrganizations>(_onSearchOrganizations);
on<AdvancedSearchOrganizations>(_onAdvancedSearchOrganizations);
on<LoadOrganizationById>(_onLoadOrganizationById);
on<CreateOrganization>(_onCreateOrganization);
on<UpdateOrganization>(_onUpdateOrganization);
on<DeleteOrganization>(_onDeleteOrganization);
on<ActivateOrganization>(_onActivateOrganization);
on<FilterOrganizationsByStatus>(_onFilterOrganizationsByStatus);
on<FilterOrganizationsByType>(_onFilterOrganizationsByType);
on<SortOrganizations>(_onSortOrganizations);
on<LoadOrganizationsStats>(_onLoadOrganizationsStats);
on<ClearOrganizationsFilters>(_onClearOrganizationsFilters);
on<RefreshOrganizations>(_onRefreshOrganizations);
on<ResetOrganizationsState>(_onResetOrganizationsState);
}
/// Charge la liste des organisations
Future<void> _onLoadOrganizations(
LoadOrganizations event,
Emitter<OrganizationsState> emit,
) async {
try {
if (event.refresh || state is! OrganizationsLoaded) {
emit(const OrganizationsLoading());
}
final organizations = await _organizationService.getOrganizations(
page: event.page,
size: event.size,
recherche: event.recherche,
);
emit(OrganizationsLoaded(
organizations: organizations,
filteredOrganizations: organizations,
hasReachedMax: organizations.length < event.size,
currentPage: event.page,
currentSearch: event.recherche,
));
} catch (e) {
emit(OrganizationsError(
'Erreur lors du chargement des organisations',
details: e.toString(),
));
}
}
/// Charge plus d'organisations (pagination)
Future<void> _onLoadMoreOrganizations(
LoadMoreOrganizations event,
Emitter<OrganizationsState> emit,
) async {
final currentState = state;
if (currentState is! OrganizationsLoaded || currentState.hasReachedMax) {
return;
}
emit(OrganizationsLoadingMore(currentState.organizations));
try {
final nextPage = currentState.currentPage + 1;
final newOrganizations = await _organizationService.getOrganizations(
page: nextPage,
size: 20,
recherche: currentState.currentSearch,
);
final allOrganizations = [...currentState.organizations, ...newOrganizations];
final filteredOrganizations = _applyCurrentFilters(allOrganizations, currentState);
emit(currentState.copyWith(
organizations: allOrganizations,
filteredOrganizations: filteredOrganizations,
hasReachedMax: newOrganizations.length < 20,
currentPage: nextPage,
));
} catch (e) {
emit(OrganizationsError(
'Erreur lors du chargement de plus d\'organisations',
details: e.toString(),
previousOrganizations: currentState.organizations,
));
}
}
/// Recherche des organisations
Future<void> _onSearchOrganizations(
SearchOrganizations event,
Emitter<OrganizationsState> emit,
) async {
final currentState = state;
if (currentState is! OrganizationsLoaded) {
// Si pas encore chargé, charger avec recherche
add(LoadOrganizations(recherche: event.query, refresh: true));
return;
}
try {
if (event.query.isEmpty) {
// Recherche vide, afficher toutes les organisations
final filteredOrganizations = _applyCurrentFilters(
currentState.organizations,
currentState.copyWith(clearSearch: true),
);
emit(currentState.copyWith(
filteredOrganizations: filteredOrganizations,
clearSearch: true,
));
} else {
// Recherche locale d'abord
final localResults = _organizationService.searchLocal(
currentState.organizations,
event.query,
);
emit(currentState.copyWith(
filteredOrganizations: localResults,
currentSearch: event.query,
));
// Puis recherche serveur pour plus de résultats
final serverResults = await _organizationService.getOrganizations(
page: 0,
size: 50,
recherche: event.query,
);
final filteredResults = _applyCurrentFilters(serverResults, currentState);
emit(currentState.copyWith(
organizations: serverResults,
filteredOrganizations: filteredResults,
currentSearch: event.query,
currentPage: 0,
hasReachedMax: true,
));
}
} catch (e) {
emit(OrganizationsError(
'Erreur lors de la recherche',
details: e.toString(),
previousOrganizations: currentState.organizations,
));
}
}
/// Recherche avancée
Future<void> _onAdvancedSearchOrganizations(
AdvancedSearchOrganizations event,
Emitter<OrganizationsState> emit,
) async {
emit(const OrganizationsLoading());
try {
final organizations = await _organizationService.searchOrganizations(
nom: event.nom,
type: event.type,
statut: event.statut,
ville: event.ville,
region: event.region,
pays: event.pays,
page: event.page,
size: event.size,
);
emit(OrganizationsLoaded(
organizations: organizations,
filteredOrganizations: organizations,
hasReachedMax: organizations.length < event.size,
currentPage: event.page,
typeFilter: event.type,
statusFilter: event.statut,
));
} catch (e) {
emit(OrganizationsError(
'Erreur lors de la recherche avancée',
details: e.toString(),
));
}
}
/// Charge une organisation par ID
Future<void> _onLoadOrganizationById(
LoadOrganizationById event,
Emitter<OrganizationsState> emit,
) async {
emit(OrganizationLoading(event.id));
try {
final organization = await _organizationService.getOrganizationById(event.id);
if (organization != null) {
emit(OrganizationLoaded(organization));
} else {
emit(OrganizationError('Organisation non trouvée', organizationId: event.id));
}
} catch (e) {
emit(OrganizationError(
'Erreur lors du chargement de l\'organisation',
organizationId: event.id,
));
}
}
/// Crée une nouvelle organisation
Future<void> _onCreateOrganization(
CreateOrganization event,
Emitter<OrganizationsState> emit,
) async {
emit(const OrganizationCreating());
try {
final createdOrganization = await _organizationService.createOrganization(event.organization);
emit(OrganizationCreated(createdOrganization));
// Recharger la liste si elle était déjà chargée
if (state is OrganizationsLoaded) {
add(const RefreshOrganizations());
}
} catch (e) {
emit(OrganizationsError(
'Erreur lors de la création de l\'organisation',
details: e.toString(),
));
}
}
/// Met à jour une organisation
Future<void> _onUpdateOrganization(
UpdateOrganization event,
Emitter<OrganizationsState> emit,
) async {
emit(OrganizationUpdating(event.id));
try {
final updatedOrganization = await _organizationService.updateOrganization(
event.id,
event.organization,
);
emit(OrganizationUpdated(updatedOrganization));
// Mettre à jour la liste si elle était déjà chargée
final currentState = state;
if (currentState is OrganizationsLoaded) {
final updatedList = currentState.organizations.map((org) {
return org.id == event.id ? updatedOrganization : org;
}).toList();
final filteredList = _applyCurrentFilters(updatedList, currentState);
emit(currentState.copyWith(
organizations: updatedList,
filteredOrganizations: filteredList,
));
}
} catch (e) {
emit(OrganizationsError(
'Erreur lors de la mise à jour de l\'organisation',
details: e.toString(),
));
}
}
/// Supprime une organisation
Future<void> _onDeleteOrganization(
DeleteOrganization event,
Emitter<OrganizationsState> emit,
) async {
emit(OrganizationDeleting(event.id));
try {
await _organizationService.deleteOrganization(event.id);
emit(OrganizationDeleted(event.id));
// Retirer de la liste si elle était déjà chargée
final currentState = state;
if (currentState is OrganizationsLoaded) {
final updatedList = currentState.organizations.where((org) => org.id != event.id).toList();
final filteredList = _applyCurrentFilters(updatedList, currentState);
emit(currentState.copyWith(
organizations: updatedList,
filteredOrganizations: filteredList,
));
}
} catch (e) {
emit(OrganizationsError(
'Erreur lors de la suppression de l\'organisation',
details: e.toString(),
));
}
}
/// Active une organisation
Future<void> _onActivateOrganization(
ActivateOrganization event,
Emitter<OrganizationsState> emit,
) async {
emit(OrganizationActivating(event.id));
try {
final activatedOrganization = await _organizationService.activateOrganization(event.id);
emit(OrganizationActivated(activatedOrganization));
// Mettre à jour la liste si elle était déjà chargée
final currentState = state;
if (currentState is OrganizationsLoaded) {
final updatedList = currentState.organizations.map((org) {
return org.id == event.id ? activatedOrganization : org;
}).toList();
final filteredList = _applyCurrentFilters(updatedList, currentState);
emit(currentState.copyWith(
organizations: updatedList,
filteredOrganizations: filteredList,
));
}
} catch (e) {
emit(OrganizationsError(
'Erreur lors de l\'activation de l\'organisation',
details: e.toString(),
));
}
}
/// Filtre par statut
void _onFilterOrganizationsByStatus(
FilterOrganizationsByStatus event,
Emitter<OrganizationsState> emit,
) {
final currentState = state;
if (currentState is! OrganizationsLoaded) return;
final filteredOrganizations = _applyCurrentFilters(
currentState.organizations,
currentState.copyWith(statusFilter: event.statut),
);
emit(currentState.copyWith(
filteredOrganizations: filteredOrganizations,
statusFilter: event.statut,
));
}
/// Filtre par type
void _onFilterOrganizationsByType(
FilterOrganizationsByType event,
Emitter<OrganizationsState> emit,
) {
final currentState = state;
if (currentState is! OrganizationsLoaded) return;
final filteredOrganizations = _applyCurrentFilters(
currentState.organizations,
currentState.copyWith(typeFilter: event.type),
);
emit(currentState.copyWith(
filteredOrganizations: filteredOrganizations,
typeFilter: event.type,
));
}
/// Trie les organisations
void _onSortOrganizations(
SortOrganizations event,
Emitter<OrganizationsState> emit,
) {
final currentState = state;
if (currentState is! OrganizationsLoaded) return;
List<OrganizationModel> sortedOrganizations;
switch (event.sortType) {
case OrganizationSortType.name:
sortedOrganizations = _organizationService.sortByName(
currentState.filteredOrganizations,
ascending: event.ascending,
);
break;
case OrganizationSortType.creationDate:
sortedOrganizations = _organizationService.sortByCreationDate(
currentState.filteredOrganizations,
ascending: event.ascending,
);
break;
case OrganizationSortType.memberCount:
sortedOrganizations = _organizationService.sortByMemberCount(
currentState.filteredOrganizations,
ascending: event.ascending,
);
break;
default:
sortedOrganizations = currentState.filteredOrganizations;
}
emit(currentState.copyWith(
filteredOrganizations: sortedOrganizations,
sortType: event.sortType,
sortAscending: event.ascending,
));
}
/// Charge les statistiques
Future<void> _onLoadOrganizationsStats(
LoadOrganizationsStats event,
Emitter<OrganizationsState> emit,
) async {
emit(const OrganizationsStatsLoading());
try {
final stats = await _organizationService.getOrganizationsStats();
emit(OrganizationsStatsLoaded(stats));
} catch (e) {
emit(const OrganizationsStatsError('Erreur lors du chargement des statistiques'));
}
}
/// Efface les filtres
void _onClearOrganizationsFilters(
ClearOrganizationsFilters event,
Emitter<OrganizationsState> emit,
) {
final currentState = state;
if (currentState is! OrganizationsLoaded) return;
emit(currentState.copyWith(
filteredOrganizations: currentState.organizations,
clearSearch: true,
clearStatusFilter: true,
clearTypeFilter: true,
clearSort: true,
));
}
/// Rafraîchit les données
void _onRefreshOrganizations(
RefreshOrganizations event,
Emitter<OrganizationsState> emit,
) {
add(const LoadOrganizations(refresh: true));
}
/// Remet à zéro l'état
void _onResetOrganizationsState(
ResetOrganizationsState event,
Emitter<OrganizationsState> emit,
) {
emit(const OrganizationsInitial());
}
/// Applique les filtres actuels à une liste d'organisations
List<OrganizationModel> _applyCurrentFilters(
List<OrganizationModel> organizations,
OrganizationsLoaded state,
) {
var filtered = organizations;
// Filtre par recherche
if (state.currentSearch?.isNotEmpty == true) {
filtered = _organizationService.searchLocal(filtered, state.currentSearch!);
}
// Filtre par statut
if (state.statusFilter != null) {
filtered = _organizationService.filterByStatus(filtered, state.statusFilter!);
}
// Filtre par type
if (state.typeFilter != null) {
filtered = _organizationService.filterByType(filtered, state.typeFilter!);
}
return filtered;
}
}

View File

@@ -0,0 +1,176 @@
/// Événements pour le BLoC des organisations
library organizations_event;
import 'package:equatable/equatable.dart';
import '../data/models/organization_model.dart';
/// Classe de base pour tous les événements des organisations
abstract class OrganizationsEvent extends Equatable {
const OrganizationsEvent();
@override
List<Object?> get props => [];
}
/// Événement pour charger la liste des organisations
class LoadOrganizations extends OrganizationsEvent {
final int page;
final int size;
final String? recherche;
final bool refresh;
const LoadOrganizations({
this.page = 0,
this.size = 20,
this.recherche,
this.refresh = false,
});
@override
List<Object?> get props => [page, size, recherche, refresh];
}
/// Événement pour charger plus d'organisations (pagination)
class LoadMoreOrganizations extends OrganizationsEvent {
const LoadMoreOrganizations();
}
/// Événement pour rechercher des organisations
class SearchOrganizations extends OrganizationsEvent {
final String query;
const SearchOrganizations(this.query);
@override
List<Object?> get props => [query];
}
/// Événement pour recherche avancée
class AdvancedSearchOrganizations extends OrganizationsEvent {
final String? nom;
final TypeOrganization? type;
final StatutOrganization? statut;
final String? ville;
final String? region;
final String? pays;
final int page;
final int size;
const AdvancedSearchOrganizations({
this.nom,
this.type,
this.statut,
this.ville,
this.region,
this.pays,
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [nom, type, statut, ville, region, pays, page, size];
}
/// Événement pour charger une organisation spécifique
class LoadOrganizationById extends OrganizationsEvent {
final String id;
const LoadOrganizationById(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour créer une nouvelle organisation
class CreateOrganization extends OrganizationsEvent {
final OrganizationModel organization;
const CreateOrganization(this.organization);
@override
List<Object?> get props => [organization];
}
/// Événement pour mettre à jour une organisation
class UpdateOrganization extends OrganizationsEvent {
final String id;
final OrganizationModel organization;
const UpdateOrganization(this.id, this.organization);
@override
List<Object?> get props => [id, organization];
}
/// Événement pour supprimer une organisation
class DeleteOrganization extends OrganizationsEvent {
final String id;
const DeleteOrganization(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour activer une organisation
class ActivateOrganization extends OrganizationsEvent {
final String id;
const ActivateOrganization(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour filtrer les organisations par statut
class FilterOrganizationsByStatus extends OrganizationsEvent {
final StatutOrganization? statut;
const FilterOrganizationsByStatus(this.statut);
@override
List<Object?> get props => [statut];
}
/// Événement pour filtrer les organisations par type
class FilterOrganizationsByType extends OrganizationsEvent {
final TypeOrganization? type;
const FilterOrganizationsByType(this.type);
@override
List<Object?> get props => [type];
}
/// Événement pour trier les organisations
class SortOrganizations extends OrganizationsEvent {
final OrganizationSortType sortType;
final bool ascending;
const SortOrganizations(this.sortType, {this.ascending = true});
@override
List<Object?> get props => [sortType, ascending];
}
/// Événement pour charger les statistiques des organisations
class LoadOrganizationsStats extends OrganizationsEvent {
const LoadOrganizationsStats();
}
/// Événement pour effacer les filtres
class ClearOrganizationsFilters extends OrganizationsEvent {
const ClearOrganizationsFilters();
}
/// Événement pour rafraîchir les données
class RefreshOrganizations extends OrganizationsEvent {
const RefreshOrganizations();
}
/// Événement pour réinitialiser l'état
class ResetOrganizationsState extends OrganizationsEvent {
const ResetOrganizationsState();
}

View File

@@ -0,0 +1,281 @@
/// États pour le BLoC des organisations
library organizations_state;
import 'package:equatable/equatable.dart';
import '../data/models/organization_model.dart';
/// Classe de base pour tous les états des organisations
abstract class OrganizationsState extends Equatable {
const OrganizationsState();
@override
List<Object?> get props => [];
}
/// État initial
class OrganizationsInitial extends OrganizationsState {
const OrganizationsInitial();
}
/// État de chargement
class OrganizationsLoading extends OrganizationsState {
const OrganizationsLoading();
}
/// État de chargement de plus d'éléments (pagination)
class OrganizationsLoadingMore extends OrganizationsState {
final List<OrganizationModel> currentOrganizations;
const OrganizationsLoadingMore(this.currentOrganizations);
@override
List<Object?> get props => [currentOrganizations];
}
/// État de succès avec données
class OrganizationsLoaded extends OrganizationsState {
final List<OrganizationModel> organizations;
final List<OrganizationModel> filteredOrganizations;
final bool hasReachedMax;
final int currentPage;
final String? currentSearch;
final StatutOrganization? statusFilter;
final TypeOrganization? typeFilter;
final OrganizationSortType? sortType;
final bool sortAscending;
final Map<String, dynamic>? stats;
const OrganizationsLoaded({
required this.organizations,
required this.filteredOrganizations,
this.hasReachedMax = false,
this.currentPage = 0,
this.currentSearch,
this.statusFilter,
this.typeFilter,
this.sortType,
this.sortAscending = true,
this.stats,
});
/// Copie avec modifications
OrganizationsLoaded copyWith({
List<OrganizationModel>? organizations,
List<OrganizationModel>? filteredOrganizations,
bool? hasReachedMax,
int? currentPage,
String? currentSearch,
StatutOrganization? statusFilter,
TypeOrganization? typeFilter,
OrganizationSortType? sortType,
bool? sortAscending,
Map<String, dynamic>? stats,
bool clearSearch = false,
bool clearStatusFilter = false,
bool clearTypeFilter = false,
bool clearSort = false,
}) {
return OrganizationsLoaded(
organizations: organizations ?? this.organizations,
filteredOrganizations: filteredOrganizations ?? this.filteredOrganizations,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
currentSearch: clearSearch ? null : (currentSearch ?? this.currentSearch),
statusFilter: clearStatusFilter ? null : (statusFilter ?? this.statusFilter),
typeFilter: clearTypeFilter ? null : (typeFilter ?? this.typeFilter),
sortType: clearSort ? null : (sortType ?? this.sortType),
sortAscending: sortAscending ?? this.sortAscending,
stats: stats ?? this.stats,
);
}
/// Nombre total d'organisations
int get totalCount => organizations.length;
/// Nombre d'organisations filtrées
int get filteredCount => filteredOrganizations.length;
/// Indique si des filtres sont appliqués
bool get hasFilters =>
currentSearch?.isNotEmpty == true ||
statusFilter != null ||
typeFilter != null;
/// Indique si un tri est appliqué
bool get hasSorting => sortType != null;
/// Statistiques rapides
Map<String, int> get quickStats {
final actives = organizations.where((org) => org.statut == StatutOrganization.active).length;
final inactives = organizations.length - actives;
final totalMembres = organizations.fold<int>(0, (sum, org) => sum + org.nombreMembres);
return {
'total': organizations.length,
'actives': actives,
'inactives': inactives,
'totalMembres': totalMembres,
};
}
@override
List<Object?> get props => [
organizations,
filteredOrganizations,
hasReachedMax,
currentPage,
currentSearch,
statusFilter,
typeFilter,
sortType,
sortAscending,
stats,
];
}
/// État d'erreur
class OrganizationsError extends OrganizationsState {
final String message;
final String? details;
final List<OrganizationModel>? previousOrganizations;
const OrganizationsError(
this.message, {
this.details,
this.previousOrganizations,
});
@override
List<Object?> get props => [message, details, previousOrganizations];
}
/// État de chargement d'une organisation spécifique
class OrganizationLoading extends OrganizationsState {
final String id;
const OrganizationLoading(this.id);
@override
List<Object?> get props => [id];
}
/// État d'organisation chargée
class OrganizationLoaded extends OrganizationsState {
final OrganizationModel organization;
const OrganizationLoaded(this.organization);
@override
List<Object?> get props => [organization];
}
/// État d'erreur pour une organisation spécifique
class OrganizationError extends OrganizationsState {
final String message;
final String? organizationId;
const OrganizationError(this.message, {this.organizationId});
@override
List<Object?> get props => [message, organizationId];
}
/// État de création d'organisation
class OrganizationCreating extends OrganizationsState {
const OrganizationCreating();
}
/// État de succès de création
class OrganizationCreated extends OrganizationsState {
final OrganizationModel organization;
const OrganizationCreated(this.organization);
@override
List<Object?> get props => [organization];
}
/// État de mise à jour d'organisation
class OrganizationUpdating extends OrganizationsState {
final String id;
const OrganizationUpdating(this.id);
@override
List<Object?> get props => [id];
}
/// État de succès de mise à jour
class OrganizationUpdated extends OrganizationsState {
final OrganizationModel organization;
const OrganizationUpdated(this.organization);
@override
List<Object?> get props => [organization];
}
/// État de suppression d'organisation
class OrganizationDeleting extends OrganizationsState {
final String id;
const OrganizationDeleting(this.id);
@override
List<Object?> get props => [id];
}
/// État de succès de suppression
class OrganizationDeleted extends OrganizationsState {
final String id;
const OrganizationDeleted(this.id);
@override
List<Object?> get props => [id];
}
/// État d'activation d'organisation
class OrganizationActivating extends OrganizationsState {
final String id;
const OrganizationActivating(this.id);
@override
List<Object?> get props => [id];
}
/// État de succès d'activation
class OrganizationActivated extends OrganizationsState {
final OrganizationModel organization;
const OrganizationActivated(this.organization);
@override
List<Object?> get props => [organization];
}
/// État de chargement des statistiques
class OrganizationsStatsLoading extends OrganizationsState {
const OrganizationsStatsLoading();
}
/// État des statistiques chargées
class OrganizationsStatsLoaded extends OrganizationsState {
final Map<String, dynamic> stats;
const OrganizationsStatsLoaded(this.stats);
@override
List<Object?> get props => [stats];
}
/// État d'erreur des statistiques
class OrganizationsStatsError extends OrganizationsState {
final String message;
const OrganizationsStatsError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,434 @@
/// Modèle de données pour les organisations
/// Correspond au OrganizationDTO du backend
library organization_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'organization_model.g.dart';
/// Énumération des types d'organisation
enum TypeOrganization {
@JsonValue('ASSOCIATION')
association,
@JsonValue('COOPERATIVE')
cooperative,
@JsonValue('LIONS_CLUB')
lionsClub,
@JsonValue('ENTREPRISE')
entreprise,
@JsonValue('ONG')
ong,
@JsonValue('FONDATION')
fondation,
@JsonValue('SYNDICAT')
syndicat,
@JsonValue('AUTRE')
autre,
}
/// Énumération des statuts d'organisation
enum StatutOrganization {
@JsonValue('ACTIVE')
active,
@JsonValue('INACTIVE')
inactive,
@JsonValue('SUSPENDUE')
suspendue,
@JsonValue('DISSOUTE')
dissoute,
@JsonValue('EN_CREATION')
enCreation,
}
/// Extension pour les types d'organisation
extension TypeOrganizationExtension on TypeOrganization {
String get displayName {
switch (this) {
case TypeOrganization.association:
return 'Association';
case TypeOrganization.cooperative:
return 'Coopérative';
case TypeOrganization.lionsClub:
return 'Lions Club';
case TypeOrganization.entreprise:
return 'Entreprise';
case TypeOrganization.ong:
return 'ONG';
case TypeOrganization.fondation:
return 'Fondation';
case TypeOrganization.syndicat:
return 'Syndicat';
case TypeOrganization.autre:
return 'Autre';
}
}
String get icon {
switch (this) {
case TypeOrganization.association:
return '🏛️';
case TypeOrganization.cooperative:
return '🤝';
case TypeOrganization.lionsClub:
return '🦁';
case TypeOrganization.entreprise:
return '🏢';
case TypeOrganization.ong:
return '🌍';
case TypeOrganization.fondation:
return '🏛️';
case TypeOrganization.syndicat:
return '⚖️';
case TypeOrganization.autre:
return '📋';
}
}
}
/// Extension pour les statuts d'organisation
extension StatutOrganizationExtension on StatutOrganization {
String get displayName {
switch (this) {
case StatutOrganization.active:
return 'Active';
case StatutOrganization.inactive:
return 'Inactive';
case StatutOrganization.suspendue:
return 'Suspendue';
case StatutOrganization.dissoute:
return 'Dissoute';
case StatutOrganization.enCreation:
return 'En création';
}
}
String get color {
switch (this) {
case StatutOrganization.active:
return '#10B981'; // Vert
case StatutOrganization.inactive:
return '#6B7280'; // Gris
case StatutOrganization.suspendue:
return '#F59E0B'; // Orange
case StatutOrganization.dissoute:
return '#EF4444'; // Rouge
case StatutOrganization.enCreation:
return '#3B82F6'; // Bleu
}
}
}
/// Énumération des types de tri pour les organisations
enum OrganizationSortType {
name,
creationDate,
memberCount,
type,
status,
}
/// Extension pour les types de tri d'organisation
extension OrganizationSortTypeExtension on OrganizationSortType {
String get displayName {
switch (this) {
case OrganizationSortType.name:
return 'Nom';
case OrganizationSortType.creationDate:
return 'Date de création';
case OrganizationSortType.memberCount:
return 'Nombre de membres';
case OrganizationSortType.type:
return 'Type';
case OrganizationSortType.status:
return 'Statut';
}
}
}
/// Modèle d'organisation mobile
@JsonSerializable()
class OrganizationModel extends Equatable {
/// Identifiant unique
final String? id;
/// Nom de l'organisation
final String nom;
/// Nom court ou sigle
final String? nomCourt;
/// Type d'organisation
@JsonKey(name: 'typeOrganisation')
final TypeOrganization typeOrganisation;
/// Statut de l'organisation
final StatutOrganization statut;
/// Description
final String? description;
/// Date de fondation
@JsonKey(name: 'dateFondation')
final DateTime? dateFondation;
/// Numéro d'enregistrement officiel
@JsonKey(name: 'numeroEnregistrement')
final String? numeroEnregistrement;
/// Email de contact
final String? email;
/// Téléphone
final String? telephone;
/// Site web
@JsonKey(name: 'siteWeb')
final String? siteWeb;
/// 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;
/// Logo URL
final String? logo;
/// Nombre de membres
@JsonKey(name: 'nombreMembres')
final int nombreMembres;
/// Nombre d'administrateurs
@JsonKey(name: 'nombreAdministrateurs')
final int nombreAdministrateurs;
/// Budget annuel
@JsonKey(name: 'budgetAnnuel')
final double? budgetAnnuel;
/// Devise
final String devise;
/// Cotisation obligatoire
@JsonKey(name: 'cotisationObligatoire')
final bool cotisationObligatoire;
/// Montant cotisation annuelle
@JsonKey(name: 'montantCotisationAnnuelle')
final double? montantCotisationAnnuelle;
/// Objectifs
final String? objectifs;
/// Activités principales
@JsonKey(name: 'activitesPrincipales')
final String? activitesPrincipales;
/// Certifications
final String? certifications;
/// Partenaires
final String? partenaires;
/// Organisation publique
@JsonKey(name: 'organisationPublique')
final bool organisationPublique;
/// Accepte nouveaux membres
@JsonKey(name: 'accepteNouveauxMembres')
final bool accepteNouveauxMembres;
/// Date de création
@JsonKey(name: 'dateCreation')
final DateTime? dateCreation;
/// Date de modification
@JsonKey(name: 'dateModification')
final DateTime? dateModification;
/// Actif
final bool actif;
const OrganizationModel({
this.id,
required this.nom,
this.nomCourt,
this.typeOrganisation = TypeOrganization.association,
this.statut = StatutOrganization.active,
this.description,
this.dateFondation,
this.numeroEnregistrement,
this.email,
this.telephone,
this.siteWeb,
this.adresse,
this.ville,
this.codePostal,
this.region,
this.pays,
this.logo,
this.nombreMembres = 0,
this.nombreAdministrateurs = 0,
this.budgetAnnuel,
this.devise = 'XOF',
this.cotisationObligatoire = false,
this.montantCotisationAnnuelle,
this.objectifs,
this.activitesPrincipales,
this.certifications,
this.partenaires,
this.organisationPublique = true,
this.accepteNouveauxMembres = true,
this.dateCreation,
this.dateModification,
this.actif = true,
});
/// Factory depuis JSON
factory OrganizationModel.fromJson(Map<String, dynamic> json) =>
_$OrganizationModelFromJson(json);
/// Conversion vers JSON
Map<String, dynamic> toJson() => _$OrganizationModelToJson(this);
/// Copie avec modifications
OrganizationModel copyWith({
String? id,
String? nom,
String? nomCourt,
TypeOrganization? typeOrganisation,
StatutOrganization? statut,
String? description,
DateTime? dateFondation,
String? numeroEnregistrement,
String? email,
String? telephone,
String? siteWeb,
String? adresse,
String? ville,
String? codePostal,
String? region,
String? pays,
String? logo,
int? nombreMembres,
int? nombreAdministrateurs,
double? budgetAnnuel,
String? devise,
bool? cotisationObligatoire,
double? montantCotisationAnnuelle,
String? objectifs,
String? activitesPrincipales,
String? certifications,
String? partenaires,
bool? organisationPublique,
bool? accepteNouveauxMembres,
DateTime? dateCreation,
DateTime? dateModification,
bool? actif,
}) {
return OrganizationModel(
id: id ?? this.id,
nom: nom ?? this.nom,
nomCourt: nomCourt ?? this.nomCourt,
typeOrganisation: typeOrganisation ?? this.typeOrganisation,
statut: statut ?? this.statut,
description: description ?? this.description,
dateFondation: dateFondation ?? this.dateFondation,
numeroEnregistrement: numeroEnregistrement ?? this.numeroEnregistrement,
email: email ?? this.email,
telephone: telephone ?? this.telephone,
siteWeb: siteWeb ?? this.siteWeb,
adresse: adresse ?? this.adresse,
ville: ville ?? this.ville,
codePostal: codePostal ?? this.codePostal,
region: region ?? this.region,
pays: pays ?? this.pays,
logo: logo ?? this.logo,
nombreMembres: nombreMembres ?? this.nombreMembres,
nombreAdministrateurs: nombreAdministrateurs ?? this.nombreAdministrateurs,
budgetAnnuel: budgetAnnuel ?? this.budgetAnnuel,
devise: devise ?? this.devise,
cotisationObligatoire: cotisationObligatoire ?? this.cotisationObligatoire,
montantCotisationAnnuelle: montantCotisationAnnuelle ?? this.montantCotisationAnnuelle,
objectifs: objectifs ?? this.objectifs,
activitesPrincipales: activitesPrincipales ?? this.activitesPrincipales,
certifications: certifications ?? this.certifications,
partenaires: partenaires ?? this.partenaires,
organisationPublique: organisationPublique ?? this.organisationPublique,
accepteNouveauxMembres: accepteNouveauxMembres ?? this.accepteNouveauxMembres,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
actif: actif ?? this.actif,
);
}
/// Ancienneté en années
int get ancienneteAnnees {
if (dateFondation == null) return 0;
return DateTime.now().difference(dateFondation!).inDays ~/ 365;
}
/// 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 (region?.isNotEmpty == true) parts.add(region!);
if (pays?.isNotEmpty == true) parts.add(pays!);
return parts.join(', ');
}
/// Nom d'affichage
String get nomAffichage => nomCourt?.isNotEmpty == true ? '$nomCourt ($nom)' : nom;
@override
List<Object?> get props => [
id,
nom,
nomCourt,
typeOrganisation,
statut,
description,
dateFondation,
numeroEnregistrement,
email,
telephone,
siteWeb,
adresse,
ville,
codePostal,
region,
pays,
logo,
nombreMembres,
nombreAdministrateurs,
budgetAnnuel,
devise,
cotisationObligatoire,
montantCotisationAnnuelle,
objectifs,
activitesPrincipales,
certifications,
partenaires,
organisationPublique,
accepteNouveauxMembres,
dateCreation,
dateModification,
actif,
];
@override
String toString() => 'OrganisationModel(id: $id, nom: $nom, type: $typeOrganisation, statut: $statut)';
}

View File

@@ -0,0 +1,110 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'organization_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
OrganizationModel _$OrganizationModelFromJson(Map<String, dynamic> json) =>
OrganizationModel(
id: json['id'] as String?,
nom: json['nom'] as String,
nomCourt: json['nomCourt'] as String?,
typeOrganisation: $enumDecodeNullable(
_$TypeOrganizationEnumMap, json['typeOrganisation']) ??
TypeOrganization.association,
statut:
$enumDecodeNullable(_$StatutOrganizationEnumMap, json['statut']) ??
StatutOrganization.active,
description: json['description'] as String?,
dateFondation: json['dateFondation'] == null
? null
: DateTime.parse(json['dateFondation'] as String),
numeroEnregistrement: json['numeroEnregistrement'] as String?,
email: json['email'] as String?,
telephone: json['telephone'] as String?,
siteWeb: json['siteWeb'] as String?,
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?,
logo: json['logo'] as String?,
nombreMembres: (json['nombreMembres'] as num?)?.toInt() ?? 0,
nombreAdministrateurs:
(json['nombreAdministrateurs'] as num?)?.toInt() ?? 0,
budgetAnnuel: (json['budgetAnnuel'] as num?)?.toDouble(),
devise: json['devise'] as String? ?? 'XOF',
cotisationObligatoire: json['cotisationObligatoire'] as bool? ?? false,
montantCotisationAnnuelle:
(json['montantCotisationAnnuelle'] as num?)?.toDouble(),
objectifs: json['objectifs'] as String?,
activitesPrincipales: json['activitesPrincipales'] as String?,
certifications: json['certifications'] as String?,
partenaires: json['partenaires'] as String?,
organisationPublique: json['organisationPublique'] as bool? ?? true,
accepteNouveauxMembres: json['accepteNouveauxMembres'] as bool? ?? true,
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> _$OrganizationModelToJson(OrganizationModel instance) =>
<String, dynamic>{
'id': instance.id,
'nom': instance.nom,
'nomCourt': instance.nomCourt,
'typeOrganisation': _$TypeOrganizationEnumMap[instance.typeOrganisation]!,
'statut': _$StatutOrganizationEnumMap[instance.statut]!,
'description': instance.description,
'dateFondation': instance.dateFondation?.toIso8601String(),
'numeroEnregistrement': instance.numeroEnregistrement,
'email': instance.email,
'telephone': instance.telephone,
'siteWeb': instance.siteWeb,
'adresse': instance.adresse,
'ville': instance.ville,
'codePostal': instance.codePostal,
'region': instance.region,
'pays': instance.pays,
'logo': instance.logo,
'nombreMembres': instance.nombreMembres,
'nombreAdministrateurs': instance.nombreAdministrateurs,
'budgetAnnuel': instance.budgetAnnuel,
'devise': instance.devise,
'cotisationObligatoire': instance.cotisationObligatoire,
'montantCotisationAnnuelle': instance.montantCotisationAnnuelle,
'objectifs': instance.objectifs,
'activitesPrincipales': instance.activitesPrincipales,
'certifications': instance.certifications,
'partenaires': instance.partenaires,
'organisationPublique': instance.organisationPublique,
'accepteNouveauxMembres': instance.accepteNouveauxMembres,
'dateCreation': instance.dateCreation?.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
'actif': instance.actif,
};
const _$TypeOrganizationEnumMap = {
TypeOrganization.association: 'ASSOCIATION',
TypeOrganization.cooperative: 'COOPERATIVE',
TypeOrganization.lionsClub: 'LIONS_CLUB',
TypeOrganization.entreprise: 'ENTREPRISE',
TypeOrganization.ong: 'ONG',
TypeOrganization.fondation: 'FONDATION',
TypeOrganization.syndicat: 'SYNDICAT',
TypeOrganization.autre: 'AUTRE',
};
const _$StatutOrganizationEnumMap = {
StatutOrganization.active: 'ACTIVE',
StatutOrganization.inactive: 'INACTIVE',
StatutOrganization.suspendue: 'SUSPENDUE',
StatutOrganization.dissoute: 'DISSOUTE',
StatutOrganization.enCreation: 'EN_CREATION',
};

View File

@@ -0,0 +1,413 @@
/// Repository pour la gestion des organisations
/// Interface avec l'API backend OrganizationResource
library organization_repository;
import 'package:dio/dio.dart';
import '../models/organization_model.dart';
/// Interface du repository des organisations
abstract class OrganizationRepository {
/// Récupère la liste des organisations avec pagination
Future<List<OrganizationModel>> getOrganizations({
int page = 0,
int size = 20,
String? recherche,
});
/// Récupère une organisation par son ID
Future<OrganizationModel?> getOrganizationById(String id);
/// Crée une nouvelle organisation
Future<OrganizationModel> createOrganization(OrganizationModel organization);
/// Met à jour une organisation
Future<OrganizationModel> updateOrganization(String id, OrganizationModel organization);
/// Supprime une organisation
Future<void> deleteOrganization(String id);
/// Active une organisation
Future<OrganizationModel> activateOrganization(String id);
/// Recherche avancée d'organisations
Future<List<OrganizationModel>> searchOrganizations({
String? nom,
TypeOrganization? type,
StatutOrganization? statut,
String? ville,
String? region,
String? pays,
int page = 0,
int size = 20,
});
/// Récupère les statistiques des organisations
Future<Map<String, dynamic>> getOrganizationsStats();
}
/// Implémentation du repository des organisations
class OrganizationRepositoryImpl implements OrganizationRepository {
final Dio _dio;
static const String _baseUrl = '/api/organisations';
OrganizationRepositoryImpl(this._dio);
@override
Future<List<OrganizationModel>> getOrganizations({
int page = 0,
int size = 20,
String? recherche,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'size': size,
};
if (recherche?.isNotEmpty == true) {
queryParams['recherche'] = recherche;
}
final response = await _dio.get(
_baseUrl,
queryParameters: queryParams,
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((json) => OrganizationModel.fromJson(json as Map<String, dynamic>))
.toList();
} else {
throw Exception('Erreur lors de la récupération des organisations: ${response.statusCode}');
}
} on DioException catch (e) {
// En cas d'erreur réseau, retourner des données de démonstration
print('Erreur API, utilisation des données de démonstration: ${e.message}');
return _getMockOrganizations(page: page, size: size, recherche: recherche);
} catch (e) {
// En cas d'erreur inattendue, retourner des données de démonstration
print('Erreur inattendue, utilisation des données de démonstration: $e');
return _getMockOrganizations(page: page, size: size, recherche: recherche);
}
}
@override
Future<OrganizationModel?> getOrganizationById(String id) async {
try {
final response = await _dio.get('$_baseUrl/$id');
if (response.statusCode == 200) {
return OrganizationModel.fromJson(response.data as Map<String, dynamic>);
} else if (response.statusCode == 404) {
return null;
} else {
throw Exception('Erreur lors de la récupération de l\'organisation: ${response.statusCode}');
}
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
return null;
}
throw Exception('Erreur réseau lors de la récupération de l\'organisation: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la récupération de l\'organisation: $e');
}
}
@override
Future<OrganizationModel> createOrganization(OrganizationModel organization) async {
try {
final response = await _dio.post(
_baseUrl,
data: organization.toJson(),
);
if (response.statusCode == 201) {
return OrganizationModel.fromJson(response.data as Map<String, dynamic>);
} else {
throw Exception('Erreur lors de la création de l\'organisation: ${response.statusCode}');
}
} on DioException catch (e) {
if (e.response?.statusCode == 400) {
final errorData = e.response?.data;
if (errorData is Map<String, dynamic> && errorData.containsKey('error')) {
throw Exception('Données invalides: ${errorData['error']}');
}
} else if (e.response?.statusCode == 409) {
throw Exception('Une organisation avec ces informations existe déjà');
}
throw Exception('Erreur réseau lors de la création de l\'organisation: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la création de l\'organisation: $e');
}
}
@override
Future<OrganizationModel> updateOrganization(String id, OrganizationModel organization) async {
try {
final response = await _dio.put(
'$_baseUrl/$id',
data: organization.toJson(),
);
if (response.statusCode == 200) {
return OrganizationModel.fromJson(response.data as Map<String, dynamic>);
} else {
throw Exception('Erreur lors de la mise à jour de l\'organisation: ${response.statusCode}');
}
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Organisation non trouvée');
} else if (e.response?.statusCode == 400) {
final errorData = e.response?.data;
if (errorData is Map<String, dynamic> && errorData.containsKey('error')) {
throw Exception('Données invalides: ${errorData['error']}');
}
}
throw Exception('Erreur réseau lors de la mise à jour de l\'organisation: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la mise à jour de l\'organisation: $e');
}
}
@override
Future<void> deleteOrganization(String id) async {
try {
final response = await _dio.delete('$_baseUrl/$id');
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception('Erreur lors de la suppression de l\'organisation: ${response.statusCode}');
}
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Organisation non trouvée');
} else if (e.response?.statusCode == 400) {
final errorData = e.response?.data;
if (errorData is Map<String, dynamic> && errorData.containsKey('error')) {
throw Exception('Impossible de supprimer: ${errorData['error']}');
}
}
throw Exception('Erreur réseau lors de la suppression de l\'organisation: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la suppression de l\'organisation: $e');
}
}
@override
Future<OrganizationModel> activateOrganization(String id) async {
try {
final response = await _dio.post('$_baseUrl/$id/activer');
if (response.statusCode == 200) {
return OrganizationModel.fromJson(response.data as Map<String, dynamic>);
} else {
throw Exception('Erreur lors de l\'activation de l\'organisation: ${response.statusCode}');
}
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Organisation non trouvée');
}
throw Exception('Erreur réseau lors de l\'activation de l\'organisation: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de l\'activation de l\'organisation: $e');
}
}
@override
Future<List<OrganizationModel>> searchOrganizations({
String? nom,
TypeOrganization? type,
StatutOrganization? statut,
String? ville,
String? region,
String? pays,
int page = 0,
int size = 20,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'size': size,
};
if (nom?.isNotEmpty == true) queryParams['nom'] = nom;
if (type != null) queryParams['type'] = type.name.toUpperCase();
if (statut != null) queryParams['statut'] = statut.name.toUpperCase();
if (ville?.isNotEmpty == true) queryParams['ville'] = ville;
if (region?.isNotEmpty == true) queryParams['region'] = region;
if (pays?.isNotEmpty == true) queryParams['pays'] = pays;
final response = await _dio.get(
'$_baseUrl/recherche',
queryParameters: queryParams,
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data as List<dynamic>;
return data
.map((json) => OrganizationModel.fromJson(json as Map<String, dynamic>))
.toList();
} else {
throw Exception('Erreur lors de la recherche d\'organisations: ${response.statusCode}');
}
} on DioException catch (e) {
throw Exception('Erreur réseau lors de la recherche d\'organisations: ${e.message}');
} catch (e) {
throw Exception('Erreur inattendue lors de la recherche d\'organisations: $e');
}
}
@override
Future<Map<String, dynamic>> getOrganizationsStats() 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');
}
}
/// Données de démonstration pour le développement
List<OrganizationModel> _getMockOrganizations({
int page = 0,
int size = 20,
String? recherche,
}) {
final mockData = [
OrganizationModel(
id: '1',
nom: 'Syndicat des Travailleurs Unis',
nomCourt: 'STU',
description: 'Organisation syndicale représentant les travailleurs de l\'industrie',
typeOrganisation: TypeOrganization.syndicat,
statut: StatutOrganization.active,
adresse: '123 Rue de la République',
ville: 'Paris',
codePostal: '75001',
region: 'Île-de-France',
pays: 'France',
telephone: '+33 1 23 45 67 89',
email: 'contact@stu.fr',
siteWeb: 'https://www.stu.fr',
nombreMembres: 1250,
budgetAnnuel: 500000.0,
montantCotisationAnnuelle: 120.0,
dateCreation: DateTime(2020, 1, 15),
dateModification: DateTime.now(),
),
OrganizationModel(
id: '2',
nom: 'Association des Professionnels de la Santé',
nomCourt: 'APS',
description: 'Association regroupant les professionnels du secteur médical',
typeOrganisation: TypeOrganization.association,
statut: StatutOrganization.active,
adresse: '456 Avenue de la Santé',
ville: 'Lyon',
codePostal: '69000',
region: 'Auvergne-Rhône-Alpes',
pays: 'France',
telephone: '+33 4 78 90 12 34',
email: 'info@aps-sante.fr',
siteWeb: 'https://www.aps-sante.fr',
nombreMembres: 850,
budgetAnnuel: 300000.0,
montantCotisationAnnuelle: 80.0,
dateCreation: DateTime(2019, 6, 10),
dateModification: DateTime.now(),
),
OrganizationModel(
id: '3',
nom: 'Coopérative Agricole du Sud',
nomCourt: 'CAS',
description: 'Coopérative regroupant les agriculteurs de la région Sud',
typeOrganisation: TypeOrganization.cooperative,
statut: StatutOrganization.active,
adresse: '789 Route des Champs',
ville: 'Marseille',
codePostal: '13000',
region: 'Provence-Alpes-Côte d\'Azur',
pays: 'France',
telephone: '+33 4 91 23 45 67',
email: 'contact@cas-agricole.fr',
siteWeb: 'https://www.cas-agricole.fr',
nombreMembres: 420,
budgetAnnuel: 750000.0,
montantCotisationAnnuelle: 200.0,
dateCreation: DateTime(2018, 3, 20),
dateModification: DateTime.now(),
),
OrganizationModel(
id: '4',
nom: 'Fédération des Artisans',
nomCourt: 'FA',
description: 'Fédération représentant les artisans de tous secteurs',
typeOrganisation: TypeOrganization.fondation,
statut: StatutOrganization.inactive,
adresse: '321 Rue de l\'Artisanat',
ville: 'Toulouse',
codePostal: '31000',
region: 'Occitanie',
pays: 'France',
telephone: '+33 5 61 78 90 12',
email: 'secretariat@federation-artisans.fr',
siteWeb: 'https://www.federation-artisans.fr',
nombreMembres: 680,
budgetAnnuel: 400000.0,
montantCotisationAnnuelle: 150.0,
dateCreation: DateTime(2017, 9, 5),
dateModification: DateTime.now(),
),
OrganizationModel(
id: '5',
nom: 'Union des Commerçants',
nomCourt: 'UC',
description: 'Union regroupant les commerçants locaux',
typeOrganisation: TypeOrganization.entreprise,
statut: StatutOrganization.active,
adresse: '654 Boulevard du Commerce',
ville: 'Bordeaux',
codePostal: '33000',
region: 'Nouvelle-Aquitaine',
pays: 'France',
telephone: '+33 5 56 34 12 78',
email: 'contact@union-commercants.fr',
siteWeb: 'https://www.union-commercants.fr',
nombreMembres: 320,
budgetAnnuel: 180000.0,
montantCotisationAnnuelle: 90.0,
dateCreation: DateTime(2021, 11, 12),
dateModification: DateTime.now(),
),
];
// Filtrer par recherche si nécessaire
List<OrganizationModel> filteredData = mockData;
if (recherche?.isNotEmpty == true) {
final query = recherche!.toLowerCase();
filteredData = mockData.where((org) =>
org.nom.toLowerCase().contains(query) ||
(org.nomCourt?.toLowerCase().contains(query) ?? false) ||
(org.description?.toLowerCase().contains(query) ?? false) ||
(org.ville?.toLowerCase().contains(query) ?? false)
).toList();
}
// Pagination
final startIndex = page * size;
final endIndex = (startIndex + size).clamp(0, filteredData.length);
if (startIndex >= filteredData.length) {
return [];
}
return filteredData.sublist(startIndex, endIndex);
}
}

View File

@@ -0,0 +1,316 @@
/// Service pour la gestion des organisations
/// Couche de logique métier entre le repository et l'interface utilisateur
library organization_service;
import '../models/organization_model.dart';
import '../repositories/organization_repository.dart';
/// Service de gestion des organisations
class OrganizationService {
final OrganizationRepository _repository;
OrganizationService(this._repository);
/// Récupère la liste des organisations avec pagination et recherche
Future<List<OrganizationModel>> getOrganizations({
int page = 0,
int size = 20,
String? recherche,
}) async {
try {
return await _repository.getOrganizations(
page: page,
size: size,
recherche: recherche,
);
} catch (e) {
throw Exception('Erreur lors de la récupération des organisations: $e');
}
}
/// Récupère une organisation par son ID
Future<OrganizationModel?> getOrganizationById(String id) async {
if (id.isEmpty) {
throw ArgumentError('L\'ID de l\'organisation ne peut pas être vide');
}
try {
return await _repository.getOrganizationById(id);
} catch (e) {
throw Exception('Erreur lors de la récupération de l\'organisation: $e');
}
}
/// Crée une nouvelle organisation avec validation
Future<OrganizationModel> createOrganization(OrganizationModel organization) async {
// Validation des données obligatoires
_validateOrganization(organization);
try {
return await _repository.createOrganization(organization);
} catch (e) {
throw Exception('Erreur lors de la création de l\'organisation: $e');
}
}
/// Met à jour une organisation avec validation
Future<OrganizationModel> updateOrganization(String id, OrganizationModel organization) async {
if (id.isEmpty) {
throw ArgumentError('L\'ID de l\'organisation ne peut pas être vide');
}
// Validation des données obligatoires
_validateOrganization(organization);
try {
return await _repository.updateOrganization(id, organization);
} catch (e) {
throw Exception('Erreur lors de la mise à jour de l\'organisation: $e');
}
}
/// Supprime une organisation
Future<void> deleteOrganization(String id) async {
if (id.isEmpty) {
throw ArgumentError('L\'ID de l\'organisation ne peut pas être vide');
}
try {
await _repository.deleteOrganization(id);
} catch (e) {
throw Exception('Erreur lors de la suppression de l\'organisation: $e');
}
}
/// Active une organisation
Future<OrganizationModel> activateOrganization(String id) async {
if (id.isEmpty) {
throw ArgumentError('L\'ID de l\'organisation ne peut pas être vide');
}
try {
return await _repository.activateOrganization(id);
} catch (e) {
throw Exception('Erreur lors de l\'activation de l\'organisation: $e');
}
}
/// Recherche avancée d'organisations
Future<List<OrganizationModel>> searchOrganizations({
String? nom,
TypeOrganization? type,
StatutOrganization? statut,
String? ville,
String? region,
String? pays,
int page = 0,
int size = 20,
}) async {
try {
return await _repository.searchOrganizations(
nom: nom,
type: type,
statut: statut,
ville: ville,
region: region,
pays: pays,
page: page,
size: size,
);
} catch (e) {
throw Exception('Erreur lors de la recherche d\'organisations: $e');
}
}
/// Récupère les statistiques des organisations
Future<Map<String, dynamic>> getOrganizationsStats() async {
try {
return await _repository.getOrganizationsStats();
} catch (e) {
throw Exception('Erreur lors de la récupération des statistiques: $e');
}
}
/// Filtre les organisations par statut
List<OrganizationModel> filterByStatus(
List<OrganizationModel> organizations,
StatutOrganization statut,
) {
return organizations.where((org) => org.statut == statut).toList();
}
/// Filtre les organisations par type
List<OrganizationModel> filterByType(
List<OrganizationModel> organizations,
TypeOrganization type,
) {
return organizations.where((org) => org.typeOrganisation == type).toList();
}
/// Trie les organisations par nom
List<OrganizationModel> sortByName(
List<OrganizationModel> organizations, {
bool ascending = true,
}) {
final sorted = List<OrganizationModel>.from(organizations);
sorted.sort((a, b) {
final comparison = a.nom.toLowerCase().compareTo(b.nom.toLowerCase());
return ascending ? comparison : -comparison;
});
return sorted;
}
/// Trie les organisations par date de création
List<OrganizationModel> sortByCreationDate(
List<OrganizationModel> organizations, {
bool ascending = true,
}) {
final sorted = List<OrganizationModel>.from(organizations);
sorted.sort((a, b) {
final dateA = a.dateCreation ?? DateTime.fromMillisecondsSinceEpoch(0);
final dateB = b.dateCreation ?? DateTime.fromMillisecondsSinceEpoch(0);
final comparison = dateA.compareTo(dateB);
return ascending ? comparison : -comparison;
});
return sorted;
}
/// Trie les organisations par nombre de membres
List<OrganizationModel> sortByMemberCount(
List<OrganizationModel> organizations, {
bool ascending = true,
}) {
final sorted = List<OrganizationModel>.from(organizations);
sorted.sort((a, b) {
final comparison = a.nombreMembres.compareTo(b.nombreMembres);
return ascending ? comparison : -comparison;
});
return sorted;
}
/// Recherche locale dans une liste d'organisations
List<OrganizationModel> searchLocal(
List<OrganizationModel> organizations,
String query,
) {
if (query.isEmpty) return organizations;
final lowerQuery = query.toLowerCase();
return organizations.where((org) {
return org.nom.toLowerCase().contains(lowerQuery) ||
(org.nomCourt?.toLowerCase().contains(lowerQuery) ?? false) ||
(org.description?.toLowerCase().contains(lowerQuery) ?? false) ||
(org.ville?.toLowerCase().contains(lowerQuery) ?? false) ||
(org.region?.toLowerCase().contains(lowerQuery) ?? false);
}).toList();
}
/// Calcule les statistiques locales d'une liste d'organisations
Map<String, dynamic> calculateLocalStats(List<OrganizationModel> organizations) {
if (organizations.isEmpty) {
return {
'total': 0,
'actives': 0,
'inactives': 0,
'totalMembres': 0,
'moyenneMembres': 0.0,
'parType': <String, int>{},
'parStatut': <String, int>{},
};
}
final actives = organizations.where((org) => org.statut == StatutOrganization.active).length;
final inactives = organizations.length - actives;
final totalMembres = organizations.fold<int>(0, (sum, org) => sum + org.nombreMembres);
final moyenneMembres = totalMembres / organizations.length;
// Statistiques par type
final parType = <String, int>{};
for (final org in organizations) {
final type = org.typeOrganisation.displayName;
parType[type] = (parType[type] ?? 0) + 1;
}
// Statistiques par statut
final parStatut = <String, int>{};
for (final org in organizations) {
final statut = org.statut.displayName;
parStatut[statut] = (parStatut[statut] ?? 0) + 1;
}
return {
'total': organizations.length,
'actives': actives,
'inactives': inactives,
'totalMembres': totalMembres,
'moyenneMembres': moyenneMembres,
'parType': parType,
'parStatut': parStatut,
};
}
/// Validation des données d'organisation
void _validateOrganization(OrganizationModel organization) {
if (organization.nom.trim().isEmpty) {
throw ArgumentError('Le nom de l\'organisation est obligatoire');
}
if (organization.nom.trim().length < 2) {
throw ArgumentError('Le nom de l\'organisation doit contenir au moins 2 caractères');
}
if (organization.nom.trim().length > 200) {
throw ArgumentError('Le nom de l\'organisation ne peut pas dépasser 200 caractères');
}
if (organization.nomCourt != null && organization.nomCourt!.length > 50) {
throw ArgumentError('Le nom court ne peut pas dépasser 50 caractères');
}
if (organization.email != null && organization.email!.isNotEmpty) {
if (!_isValidEmail(organization.email!)) {
throw ArgumentError('L\'adresse email n\'est pas valide');
}
}
if (organization.telephone != null && organization.telephone!.isNotEmpty) {
if (!_isValidPhone(organization.telephone!)) {
throw ArgumentError('Le numéro de téléphone n\'est pas valide');
}
}
if (organization.siteWeb != null && organization.siteWeb!.isNotEmpty) {
if (!_isValidUrl(organization.siteWeb!)) {
throw ArgumentError('L\'URL du site web n\'est pas valide');
}
}
if (organization.budgetAnnuel != null && organization.budgetAnnuel! < 0) {
throw ArgumentError('Le budget annuel doit être positif');
}
if (organization.montantCotisationAnnuelle != null && organization.montantCotisationAnnuelle! < 0) {
throw ArgumentError('Le montant de cotisation doit être positif');
}
}
/// Validation d'email
bool _isValidEmail(String email) {
return RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$').hasMatch(email);
}
/// Validation de téléphone
bool _isValidPhone(String phone) {
return RegExp(r'^\+?[0-9\s\-\(\)]{8,15}$').hasMatch(phone);
}
/// Validation d'URL
bool _isValidUrl(String url) {
try {
final uri = Uri.parse(url);
return uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https');
} catch (e) {
return false;
}
}
}

View File

@@ -0,0 +1,59 @@
/// Configuration de l'injection de dépendances pour le module Organizations
library organizations_di;
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import '../data/repositories/organization_repository.dart';
import '../data/services/organization_service.dart';
import '../bloc/organizations_bloc.dart';
/// Configuration des dépendances du module Organizations
class OrganizationsDI {
static final GetIt _getIt = GetIt.instance;
/// Enregistre toutes les dépendances du module
static void registerDependencies() {
// Repository
_getIt.registerLazySingleton<OrganizationRepository>(
() => OrganizationRepositoryImpl(_getIt<Dio>()),
);
// Service
_getIt.registerLazySingleton<OrganizationService>(
() => OrganizationService(_getIt<OrganizationRepository>()),
);
// BLoC - Factory pour permettre plusieurs instances
_getIt.registerFactory<OrganizationsBloc>(
() => OrganizationsBloc(_getIt<OrganizationService>()),
);
}
/// Nettoie les dépendances du module
static void unregisterDependencies() {
if (_getIt.isRegistered<OrganizationsBloc>()) {
_getIt.unregister<OrganizationsBloc>();
}
if (_getIt.isRegistered<OrganizationService>()) {
_getIt.unregister<OrganizationService>();
}
if (_getIt.isRegistered<OrganizationRepository>()) {
_getIt.unregister<OrganizationRepository>();
}
}
/// Obtient une instance du BLoC
static OrganizationsBloc getOrganizationsBloc() {
return _getIt<OrganizationsBloc>();
}
/// Obtient une instance du service
static OrganizationService getOrganizationService() {
return _getIt<OrganizationService>();
}
/// Obtient une instance du repository
static OrganizationRepository getOrganizationRepository() {
return _getIt<OrganizationRepository>();
}
}

View File

@@ -0,0 +1,533 @@
/// Page de création d'une nouvelle organisation
/// Respecte strictement le design system établi dans l'application
library create_organisation_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/models/organization_model.dart';
import '../../bloc/organizations_bloc.dart';
import '../../bloc/organizations_event.dart';
import '../../bloc/organizations_state.dart';
/// Page de création d'organisation avec design system cohérent
class CreateOrganizationPage extends StatefulWidget {
const CreateOrganizationPage({super.key});
@override
State<CreateOrganizationPage> createState() => _CreateOrganizationPageState();
}
class _CreateOrganizationPageState extends State<CreateOrganizationPage> {
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _nomCourtController = TextEditingController();
final _descriptionController = TextEditingController();
final _emailController = TextEditingController();
final _telephoneController = TextEditingController();
final _siteWebController = TextEditingController();
final _adresseController = TextEditingController();
final _villeController = TextEditingController();
final _regionController = TextEditingController();
final _paysController = TextEditingController();
TypeOrganization _selectedType = TypeOrganization.association;
StatutOrganization _selectedStatut = StatutOrganization.active;
@override
void dispose() {
_nomController.dispose();
_nomCourtController.dispose();
_descriptionController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_siteWebController.dispose();
_adresseController.dispose();
_villeController.dispose();
_regionController.dispose();
_paysController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA), // Background cohérent
appBar: AppBar(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
title: const Text('Nouvelle Organisation'),
elevation: 0,
actions: [
TextButton(
onPressed: _isFormValid() ? _saveOrganisation : null,
child: const Text(
'Enregistrer',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
body: BlocListener<OrganizationsBloc, OrganizationsState>(
listener: (context, state) {
if (state is OrganizationCreated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Organisation créée avec succès'),
backgroundColor: Color(0xFF10B981),
),
);
Navigator.of(context).pop(true); // Retour avec succès
} else if (state is OrganizationsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(12), // SpacingTokens cohérent
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBasicInfoCard(),
const SizedBox(height: 16),
_buildContactCard(),
const SizedBox(height: 16),
_buildLocationCard(),
const SizedBox(height: 16),
_buildConfigurationCard(),
const SizedBox(height: 24),
_buildActionButtons(),
],
),
),
),
),
);
}
/// Carte des informations de base
Widget _buildBasicInfoCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations de base',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _nomController,
decoration: const InputDecoration(
labelText: 'Nom de l\'organisation *',
hintText: 'Ex: Association des Développeurs',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.business),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le nom est obligatoire';
}
if (value.trim().length < 3) {
return 'Le nom doit contenir au moins 3 caractères';
}
return null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
TextFormField(
controller: _nomCourtController,
decoration: const InputDecoration(
labelText: 'Nom court (optionnel)',
hintText: 'Ex: AsDev',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.short_text),
),
validator: (value) {
if (value != null && value.trim().isNotEmpty && value.trim().length < 2) {
return 'Le nom court doit contenir au moins 2 caractères';
}
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<TypeOrganization>(
value: _selectedType,
decoration: const InputDecoration(
labelText: 'Type d\'organisation *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: TypeOrganization.values.map((type) {
return DropdownMenuItem(
value: type,
child: Row(
children: [
Text(type.icon, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Text(type.displayName),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedType = value;
});
}
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optionnel)',
hintText: 'Décrivez brièvement l\'organisation...',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
maxLines: 3,
validator: (value) {
if (value != null && value.trim().isNotEmpty && value.trim().length < 10) {
return 'La description doit contenir au moins 10 caractères';
}
return null;
},
),
],
),
);
}
/// Carte des informations de contact
Widget _buildContactCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Contact',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email (optionnel)',
hintText: 'contact@organisation.com',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value != null && value.trim().isNotEmpty) {
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$');
if (!emailRegex.hasMatch(value.trim())) {
return 'Format d\'email invalide';
}
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _telephoneController,
decoration: const InputDecoration(
labelText: 'Téléphone (optionnel)',
hintText: '+225 XX XX XX XX XX',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
keyboardType: TextInputType.phone,
validator: (value) {
if (value != null && value.trim().isNotEmpty && value.trim().length < 8) {
return 'Numéro de téléphone invalide';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _siteWebController,
decoration: const InputDecoration(
labelText: 'Site web (optionnel)',
hintText: 'https://www.organisation.com',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.web),
),
keyboardType: TextInputType.url,
validator: (value) {
if (value != null && value.trim().isNotEmpty) {
final urlRegex = RegExp(r'^https?://[^\s]+$');
if (!urlRegex.hasMatch(value.trim())) {
return 'Format d\'URL invalide (doit commencer par http:// ou https://)';
}
}
return null;
},
),
],
),
);
}
/// Carte de localisation
Widget _buildLocationCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Localisation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _adresseController,
decoration: const InputDecoration(
labelText: 'Adresse (optionnel)',
hintText: 'Rue, quartier...',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
maxLines: 2,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _villeController,
decoration: const InputDecoration(
labelText: 'Ville',
hintText: 'Abidjan',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_city),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _regionController,
decoration: const InputDecoration(
labelText: 'Région',
hintText: 'Lagunes',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.map),
),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _paysController,
decoration: const InputDecoration(
labelText: 'Pays',
hintText: 'Côte d\'Ivoire',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.flag),
),
),
],
),
);
}
/// Carte de configuration
Widget _buildConfigurationCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Configuration',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<StatutOrganization>(
value: _selectedStatut,
decoration: const InputDecoration(
labelText: 'Statut initial *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.toggle_on),
),
items: StatutOrganization.values.map((statut) {
final color = Color(int.parse(statut.color.substring(1), radix: 16) + 0xFF000000);
return DropdownMenuItem(
value: statut,
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(statut.displayName),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedStatut = value;
});
}
},
),
],
),
);
}
/// Boutons d'action
Widget _buildActionButtons() {
return Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isFormValid() ? _saveOrganisation : null,
icon: const Icon(Icons.save),
label: const Text('Créer l\'organisation'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.cancel),
label: const Text('Annuler'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF6B7280),
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
);
}
/// Vérifie si le formulaire est valide
bool _isFormValid() {
return _nomController.text.trim().isNotEmpty;
}
/// Sauvegarde l'organisation
void _saveOrganisation() {
if (_formKey.currentState?.validate() ?? false) {
final organisation = OrganizationModel(
nom: _nomController.text.trim(),
nomCourt: _nomCourtController.text.trim().isEmpty ? null : _nomCourtController.text.trim(),
description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(),
typeOrganisation: _selectedType,
statut: _selectedStatut,
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
telephone: _telephoneController.text.trim().isEmpty ? null : _telephoneController.text.trim(),
siteWeb: _siteWebController.text.trim().isEmpty ? null : _siteWebController.text.trim(),
adresse: _adresseController.text.trim().isEmpty ? null : _adresseController.text.trim(),
ville: _villeController.text.trim().isEmpty ? null : _villeController.text.trim(),
region: _regionController.text.trim().isEmpty ? null : _regionController.text.trim(),
pays: _paysController.text.trim().isEmpty ? null : _paysController.text.trim(),
dateCreation: DateTime.now(),
nombreMembres: 0,
);
context.read<OrganizationsBloc>().add(CreateOrganization(organisation));
}
}
}

View File

@@ -0,0 +1,705 @@
/// Page d'édition d'une organisation existante
/// Respecte strictement le design system établi dans l'application
library edit_organisation_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/models/organization_model.dart';
import '../../bloc/organizations_bloc.dart';
import '../../bloc/organizations_event.dart';
import '../../bloc/organizations_state.dart';
/// Page d'édition d'organisation avec design system cohérent
class EditOrganizationPage extends StatefulWidget {
final OrganizationModel organization;
const EditOrganizationPage({
super.key,
required this.organization,
});
@override
State<EditOrganizationPage> createState() => _EditOrganizationPageState();
}
class _EditOrganizationPageState extends State<EditOrganizationPage> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nomController;
late final TextEditingController _nomCourtController;
late final TextEditingController _descriptionController;
late final TextEditingController _emailController;
late final TextEditingController _telephoneController;
late final TextEditingController _siteWebController;
late final TextEditingController _adresseController;
late final TextEditingController _villeController;
late final TextEditingController _regionController;
late final TextEditingController _paysController;
late TypeOrganization _selectedType;
late StatutOrganization _selectedStatut;
@override
void initState() {
super.initState();
// Initialiser les contrôleurs avec les valeurs existantes
_nomController = TextEditingController(text: widget.organization.nom);
_nomCourtController = TextEditingController(text: widget.organization.nomCourt ?? '');
_descriptionController = TextEditingController(text: widget.organization.description ?? '');
_emailController = TextEditingController(text: widget.organization.email ?? '');
_telephoneController = TextEditingController(text: widget.organization.telephone ?? '');
_siteWebController = TextEditingController(text: widget.organization.siteWeb ?? '');
_adresseController = TextEditingController(text: widget.organization.adresse ?? '');
_villeController = TextEditingController(text: widget.organization.ville ?? '');
_regionController = TextEditingController(text: widget.organization.region ?? '');
_paysController = TextEditingController(text: widget.organization.pays ?? '');
_selectedType = widget.organization.typeOrganisation;
_selectedStatut = widget.organization.statut;
}
@override
void dispose() {
_nomController.dispose();
_nomCourtController.dispose();
_descriptionController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_siteWebController.dispose();
_adresseController.dispose();
_villeController.dispose();
_regionController.dispose();
_paysController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA), // Background cohérent
appBar: AppBar(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
title: const Text('Modifier Organisation'),
elevation: 0,
actions: [
TextButton(
onPressed: _hasChanges() ? _saveChanges : null,
child: const Text(
'Enregistrer',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
body: BlocListener<OrganizationsBloc, OrganizationsState>(
listener: (context, state) {
if (state is OrganizationUpdated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Organisation modifiée avec succès'),
backgroundColor: Color(0xFF10B981),
),
);
Navigator.of(context).pop(true); // Retour avec succès
} else if (state is OrganizationsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(12), // SpacingTokens cohérent
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBasicInfoCard(),
const SizedBox(height: 16),
_buildContactCard(),
const SizedBox(height: 16),
_buildLocationCard(),
const SizedBox(height: 16),
_buildConfigurationCard(),
const SizedBox(height: 16),
_buildMetadataCard(),
const SizedBox(height: 24),
_buildActionButtons(),
],
),
),
),
),
);
}
/// Carte des informations de base
Widget _buildBasicInfoCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations de base',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _nomController,
decoration: const InputDecoration(
labelText: 'Nom de l\'organisation *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.business),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le nom est obligatoire';
}
if (value.trim().length < 3) {
return 'Le nom doit contenir au moins 3 caractères';
}
return null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
TextFormField(
controller: _nomCourtController,
decoration: const InputDecoration(
labelText: 'Nom court (optionnel)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.short_text),
),
validator: (value) {
if (value != null && value.trim().isNotEmpty && value.trim().length < 2) {
return 'Le nom court doit contenir au moins 2 caractères';
}
return null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
DropdownButtonFormField<TypeOrganization>(
value: _selectedType,
decoration: const InputDecoration(
labelText: 'Type d\'organisation *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: TypeOrganization.values.map((type) {
return DropdownMenuItem(
value: type,
child: Row(
children: [
Text(type.icon, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Text(type.displayName),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedType = value;
});
}
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optionnel)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
maxLines: 3,
validator: (value) {
if (value != null && value.trim().isNotEmpty && value.trim().length < 10) {
return 'La description doit contenir au moins 10 caractères';
}
return null;
},
onChanged: (_) => setState(() {}),
),
],
),
);
}
/// Carte des informations de contact
Widget _buildContactCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Contact',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email (optionnel)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value != null && value.trim().isNotEmpty) {
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$');
if (!emailRegex.hasMatch(value.trim())) {
return 'Format d\'email invalide';
}
}
return null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
TextFormField(
controller: _telephoneController,
decoration: const InputDecoration(
labelText: 'Téléphone (optionnel)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
keyboardType: TextInputType.phone,
validator: (value) {
if (value != null && value.trim().isNotEmpty && value.trim().length < 8) {
return 'Numéro de téléphone invalide';
}
return null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
TextFormField(
controller: _siteWebController,
decoration: const InputDecoration(
labelText: 'Site web (optionnel)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.web),
),
keyboardType: TextInputType.url,
validator: (value) {
if (value != null && value.trim().isNotEmpty) {
final urlRegex = RegExp(r'^https?://[^\s]+$');
if (!urlRegex.hasMatch(value.trim())) {
return 'Format d\'URL invalide (doit commencer par http:// ou https://)';
}
}
return null;
},
onChanged: (_) => setState(() {}),
),
],
),
);
}
/// Carte de localisation
Widget _buildLocationCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Localisation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _adresseController,
decoration: const InputDecoration(
labelText: 'Adresse (optionnel)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
maxLines: 2,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _villeController,
decoration: const InputDecoration(
labelText: 'Ville',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_city),
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _regionController,
decoration: const InputDecoration(
labelText: 'Région',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.map),
),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _paysController,
decoration: const InputDecoration(
labelText: 'Pays',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.flag),
),
onChanged: (_) => setState(() {}),
),
],
),
);
}
/// Carte de configuration
Widget _buildConfigurationCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Configuration',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<StatutOrganization>(
value: _selectedStatut,
decoration: const InputDecoration(
labelText: 'Statut *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.toggle_on),
),
items: StatutOrganization.values.map((statut) {
final color = Color(int.parse(statut.color.substring(1), radix: 16) + 0xFF000000);
return DropdownMenuItem(
value: statut,
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(statut.displayName),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedStatut = value;
});
}
},
),
],
),
);
}
/// Carte des métadonnées (lecture seule)
Widget _buildMetadataCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations système',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
_buildReadOnlyField(
icon: Icons.fingerprint,
label: 'ID',
value: widget.organization.id ?? 'Non défini',
),
const SizedBox(height: 12),
_buildReadOnlyField(
icon: Icons.calendar_today,
label: 'Date de création',
value: _formatDate(widget.organization.dateCreation),
),
const SizedBox(height: 12),
_buildReadOnlyField(
icon: Icons.people,
label: 'Nombre de membres',
value: widget.organization.nombreMembres.toString(),
),
if (widget.organization.ancienneteAnnees > 0) ...[
const SizedBox(height: 12),
_buildReadOnlyField(
icon: Icons.access_time,
label: 'Ancienneté',
value: '${widget.organization.ancienneteAnnees} ans',
),
],
],
),
);
}
/// Champ en lecture seule
Widget _buildReadOnlyField({
required IconData icon,
required String label,
required String value,
}) {
return Row(
children: [
Icon(
icon,
size: 20,
color: const Color(0xFF6B7280),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF374151),
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
/// Boutons d'action
Widget _buildActionButtons() {
return Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _hasChanges() ? _saveChanges : null,
icon: const Icon(Icons.save),
label: const Text('Enregistrer les modifications'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _showDiscardDialog(),
icon: const Icon(Icons.cancel),
label: const Text('Annuler'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF6B7280),
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
);
}
/// Vérifie s'il y a des changements
bool _hasChanges() {
return _nomController.text.trim() != widget.organization.nom ||
_nomCourtController.text.trim() != (widget.organization.nomCourt ?? '') ||
_descriptionController.text.trim() != (widget.organization.description ?? '') ||
_emailController.text.trim() != (widget.organization.email ?? '') ||
_telephoneController.text.trim() != (widget.organization.telephone ?? '') ||
_siteWebController.text.trim() != (widget.organization.siteWeb ?? '') ||
_adresseController.text.trim() != (widget.organization.adresse ?? '') ||
_villeController.text.trim() != (widget.organization.ville ?? '') ||
_regionController.text.trim() != (widget.organization.region ?? '') ||
_paysController.text.trim() != (widget.organization.pays ?? '') ||
_selectedType != widget.organization.typeOrganisation ||
_selectedStatut != widget.organization.statut;
}
/// Sauvegarde les modifications
void _saveChanges() {
if (_formKey.currentState?.validate() ?? false) {
final updatedOrganisation = widget.organization.copyWith(
nom: _nomController.text.trim(),
nomCourt: _nomCourtController.text.trim().isEmpty ? null : _nomCourtController.text.trim(),
description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(),
typeOrganisation: _selectedType,
statut: _selectedStatut,
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
telephone: _telephoneController.text.trim().isEmpty ? null : _telephoneController.text.trim(),
siteWeb: _siteWebController.text.trim().isEmpty ? null : _siteWebController.text.trim(),
adresse: _adresseController.text.trim().isEmpty ? null : _adresseController.text.trim(),
ville: _villeController.text.trim().isEmpty ? null : _villeController.text.trim(),
region: _regionController.text.trim().isEmpty ? null : _regionController.text.trim(),
pays: _paysController.text.trim().isEmpty ? null : _paysController.text.trim(),
);
if (widget.organization.id != null) {
context.read<OrganizationsBloc>().add(
UpdateOrganization(widget.organization.id!, updatedOrganisation),
);
}
}
}
/// Affiche le dialog de confirmation d'annulation
void _showDiscardDialog() {
if (_hasChanges()) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Annuler les modifications'),
content: const Text('Vous avez des modifications non sauvegardées. Êtes-vous sûr de vouloir les abandonner ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Continuer l\'édition'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop(); // Fermer le dialog
Navigator.of(context).pop(); // Retour à la page précédente
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Abandonner', style: TextStyle(color: Colors.white)),
),
],
),
);
} else {
Navigator.of(context).pop();
}
}
/// Formate une date
String _formatDate(DateTime? date) {
if (date == null) return 'Non spécifiée';
return '${date.day}/${date.month}/${date.year}';
}
}

View File

@@ -0,0 +1,790 @@
/// Page de détail d'une organisation
/// Respecte strictement le design system établi dans l'application
library organisation_detail_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/models/organization_model.dart';
import '../../bloc/organizations_bloc.dart';
import '../../bloc/organizations_event.dart';
import '../../bloc/organizations_state.dart';
/// Page de détail d'une organisation avec design system cohérent
class OrganizationDetailPage extends StatefulWidget {
final String organizationId;
const OrganizationDetailPage({
super.key,
required this.organizationId,
});
@override
State<OrganizationDetailPage> createState() => _OrganizationDetailPageState();
}
class _OrganizationDetailPageState extends State<OrganizationDetailPage> {
@override
void initState() {
super.initState();
// Charger les détails de l'organisation
context.read<OrganizationsBloc>().add(LoadOrganizationById(widget.organizationId));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA), // Background cohérent
appBar: AppBar(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
title: const Text('Détail Organisation'),
elevation: 0,
actions: [
IconButton(
onPressed: () => _showEditDialog(),
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
),
PopupMenuButton<String>(
onSelected: (value) => _handleMenuAction(value),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'activate',
child: Row(
children: [
Icon(Icons.check_circle, color: Color(0xFF10B981)),
SizedBox(width: 8),
Text('Activer'),
],
),
),
const PopupMenuItem(
value: 'deactivate',
child: Row(
children: [
Icon(Icons.pause_circle, color: Color(0xFF6B7280)),
SizedBox(width: 8),
Text('Désactiver'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Supprimer'),
],
),
),
],
),
],
),
body: BlocBuilder<OrganizationsBloc, OrganizationsState>(
builder: (context, state) {
if (state is OrganizationLoading) {
return _buildLoadingState();
} else if (state is OrganizationLoaded) {
return _buildDetailContent(state.organization);
} else if (state is OrganizationsError) {
return _buildErrorState(state);
}
return _buildEmptyState();
},
),
);
}
/// État de chargement
Widget _buildLoadingState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6C5CE7)),
),
SizedBox(height: 16),
Text(
'Chargement des détails...',
style: TextStyle(
fontSize: 16,
color: Color(0xFF6B7280),
),
),
],
),
);
}
/// Contenu principal avec les détails
Widget _buildDetailContent(OrganizationModel organization) {
return SingleChildScrollView(
padding: const EdgeInsets.all(12), // SpacingTokens cohérent
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderCard(organization),
const SizedBox(height: 16),
_buildInfoCard(organization),
const SizedBox(height: 16),
_buildStatsCard(organization),
const SizedBox(height: 16),
_buildContactCard(organization),
const SizedBox(height: 16),
_buildActionsCard(organization),
],
),
);
}
/// Carte d'en-tête avec informations principales
Widget _buildHeaderCard(OrganizationModel organization) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
const Color(0xFF6C5CE7),
const Color(0xFF6C5CE7).withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
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(8),
),
child: Text(
organization.typeOrganisation.icon,
style: const TextStyle(fontSize: 24),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
organization.nom,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
if (organization.nomCourt?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
organization.nomCourt!,
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.9),
),
),
],
const SizedBox(height: 8),
_buildStatusBadge(organization.statut),
],
),
),
],
),
if (organization.description?.isNotEmpty == true) ...[
const SizedBox(height: 16),
Text(
organization.description!,
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.9),
height: 1.4,
),
),
],
],
),
);
}
/// Badge de statut
Widget _buildStatusBadge(StatutOrganization statut) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(16),
),
child: Text(
statut.displayName,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
);
}
/// Carte d'informations générales
Widget _buildInfoCard(OrganizationModel organization) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations générales',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
_buildInfoRow(
icon: Icons.category,
label: 'Type',
value: organization.typeOrganisation.displayName,
),
const SizedBox(height: 12),
_buildInfoRow(
icon: Icons.location_on,
label: 'Localisation',
value: _buildLocationText(organization),
),
const SizedBox(height: 12),
_buildInfoRow(
icon: Icons.calendar_today,
label: 'Date de création',
value: _formatDate(organization.dateCreation),
),
if (organization.ancienneteAnnees > 0) ...[
const SizedBox(height: 12),
_buildInfoRow(
icon: Icons.access_time,
label: 'Ancienneté',
value: '${organization.ancienneteAnnees} ans',
),
],
],
),
);
}
/// Ligne d'information
Widget _buildInfoRow({
required IconData icon,
required String label,
required String value,
}) {
return Row(
children: [
Icon(
icon,
size: 20,
color: const Color(0xFF6C5CE7),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF374151),
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
/// Carte de statistiques
Widget _buildStatsCard(OrganizationModel organization) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statistiques',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
icon: Icons.people,
label: 'Membres',
value: organization.nombreMembres.toString(),
color: const Color(0xFF3B82F6),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatItem(
icon: Icons.event,
label: 'Événements',
value: '0', // TODO: Récupérer depuis l'API
color: const Color(0xFF10B981),
),
),
],
),
],
),
);
}
/// Item de statistique
Widget _buildStatItem({
required IconData icon,
required String label,
required String value,
required Color color,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: color.withOpacity(0.1),
width: 1,
),
),
child: Column(
children: [
Icon(
icon,
size: 24,
color: color,
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
/// Carte de contact
Widget _buildContactCard(OrganizationModel organization) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Contact',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
if (organization.email?.isNotEmpty == true)
_buildContactRow(
icon: Icons.email,
label: 'Email',
value: organization.email!,
onTap: () => _launchEmail(organization.email!),
),
if (organization.telephone?.isNotEmpty == true) ...[
const SizedBox(height: 12),
_buildContactRow(
icon: Icons.phone,
label: 'Téléphone',
value: organization.telephone!,
onTap: () => _launchPhone(organization.telephone!),
),
],
if (organization.siteWeb?.isNotEmpty == true) ...[
const SizedBox(height: 12),
_buildContactRow(
icon: Icons.web,
label: 'Site web',
value: organization.siteWeb!,
onTap: () => _launchWebsite(organization.siteWeb!),
),
],
],
),
);
}
/// Ligne de contact
Widget _buildContactRow({
required IconData icon,
required String label,
required String value,
VoidCallback? onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
icon,
size: 20,
color: const Color(0xFF6C5CE7),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: 14,
color: onTap != null ? const Color(0xFF6C5CE7) : const Color(0xFF374151),
fontWeight: FontWeight.w600,
decoration: onTap != null ? TextDecoration.underline : null,
),
),
],
),
),
if (onTap != null)
const Icon(
Icons.open_in_new,
size: 16,
color: Color(0xFF6C5CE7),
),
],
),
),
);
}
/// Carte d'actions
Widget _buildActionsCard(OrganizationModel organization) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Actions',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showEditDialog(),
icon: const Icon(Icons.edit),
label: const Text('Modifier'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _showDeleteConfirmation(organization),
icon: const Icon(Icons.delete),
label: const Text('Supprimer'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
),
),
),
],
),
],
),
);
}
/// État d'erreur
Widget _buildErrorState(OrganizationsError state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.shade400,
),
const SizedBox(height: 16),
Text(
'Erreur',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red.shade700,
),
),
const SizedBox(height: 8),
Text(
state.message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF6B7280),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
context.read<OrganizationsBloc>().add(LoadOrganizationById(widget.organizationId));
},
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C5CE7),
foregroundColor: Colors.white,
),
),
],
),
);
}
/// État vide
Widget _buildEmptyState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 64,
color: Color(0xFF6B7280),
),
SizedBox(height: 16),
Text(
'Organisation non trouvée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF374151),
),
),
],
),
);
}
/// Construit le texte de localisation
String _buildLocationText(OrganizationModel organization) {
final parts = <String>[];
if (organization.ville?.isNotEmpty == true) {
parts.add(organization.ville!);
}
if (organization.region?.isNotEmpty == true) {
parts.add(organization.region!);
}
if (organization.pays?.isNotEmpty == true) {
parts.add(organization.pays!);
}
return parts.isEmpty ? 'Non spécifiée' : parts.join(', ');
}
/// Formate une date
String _formatDate(DateTime? date) {
if (date == null) return 'Non spécifiée';
return '${date.day}/${date.month}/${date.year}';
}
/// Actions du menu
void _handleMenuAction(String action) {
switch (action) {
case 'activate':
context.read<OrganizationsBloc>().add(ActivateOrganization(widget.organizationId));
break;
case 'deactivate':
// TODO: Implémenter la désactivation
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Désactivation - À implémenter')),
);
break;
case 'delete':
_showDeleteConfirmation(null);
break;
}
}
/// Affiche le dialog d'édition
void _showEditDialog() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Édition - À implémenter')),
);
}
/// Affiche la confirmation de suppression
void _showDeleteConfirmation(OrganizationModel? organization) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
organization != null
? 'Êtes-vous sûr de vouloir supprimer "${organization.nom}" ?'
: 'Êtes-vous sûr de vouloir supprimer cette organisation ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
context.read<OrganizationsBloc>().add(DeleteOrganization(widget.organizationId));
Navigator.of(context).pop(); // Retour à la liste
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Supprimer', style: TextStyle(color: Colors.white)),
),
],
),
);
}
/// Lance l'application email
void _launchEmail(String email) {
// TODO: Implémenter url_launcher
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ouvrir email: $email')),
);
}
/// Lance l'application téléphone
void _launchPhone(String phone) {
// TODO: Implémenter url_launcher
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Appeler: $phone')),
);
}
/// Lance le navigateur web
void _launchWebsite(String url) {
// TODO: Implémenter url_launcher
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ouvrir site: $url')),
);
}
}

View File

@@ -0,0 +1,736 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/organizations_bloc.dart';
import '../../bloc/organizations_event.dart';
import '../../bloc/organizations_state.dart';
/// Page de gestion des organisations - Interface sophistiquée et exhaustive
///
/// Cette page offre une interface complète pour la gestion des organisations
/// avec des fonctionnalités avancées de recherche, filtrage, statistiques
/// et actions de gestion basées sur les permissions utilisateur.
class OrganizationsPage extends StatefulWidget {
const OrganizationsPage({super.key});
@override
State<OrganizationsPage> createState() => _OrganizationsPageState();
}
class _OrganizationsPageState extends State<OrganizationsPage> with TickerProviderStateMixin {
// Controllers et état
final TextEditingController _searchController = TextEditingController();
late TabController _tabController;
// État de l'interface
String _searchQuery = '';
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
// Charger les organisations au démarrage
context.read<OrganizationsBloc>().add(const LoadOrganizations());
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
// Données de démonstration enrichies
final List<Map<String, dynamic>> _allOrganisations = [
{
'id': '1',
'nom': 'Syndicat des Travailleurs Unis',
'description': 'Organisation syndicale représentant les travailleurs de l\'industrie',
'type': 'Syndicat',
'secteurActivite': 'Industrie',
'status': 'Active',
'dateCreation': DateTime(2020, 3, 15),
'dateModification': DateTime(2024, 9, 19),
'nombreMembres': 1250,
'adresse': '123 Rue de la République, Paris',
'telephone': '+33 1 23 45 67 89',
'email': 'contact@stu.org',
'siteWeb': 'https://www.stu.org',
'logo': null,
'budget': 850000,
'projetsActifs': 8,
'evenementsAnnuels': 24,
},
{
'id': '2',
'nom': 'Fédération Nationale des Employés',
'description': 'Fédération regroupant plusieurs syndicats d\'employés',
'type': 'Fédération',
'secteurActivite': 'Services',
'status': 'Active',
'dateCreation': DateTime(2018, 7, 22),
'dateModification': DateTime(2024, 9, 18),
'nombreMembres': 3500,
'adresse': '456 Avenue des Champs, Lyon',
'telephone': '+33 4 56 78 90 12',
'email': 'info@fne.org',
'siteWeb': 'https://www.fne.org',
'logo': null,
'budget': 2100000,
'projetsActifs': 15,
'evenementsAnnuels': 36,
},
{
'id': '3',
'nom': 'Union des Artisans',
'description': 'Union représentant les artisans et petites entreprises',
'type': 'Union',
'secteurActivite': 'Artisanat',
'status': 'Active',
'dateCreation': DateTime(2019, 11, 8),
'dateModification': DateTime(2024, 9, 15),
'nombreMembres': 890,
'adresse': '789 Place du Marché, Marseille',
'telephone': '+33 4 91 23 45 67',
'email': 'contact@unionartisans.org',
'siteWeb': 'https://www.unionartisans.org',
'logo': null,
'budget': 450000,
'projetsActifs': 5,
'evenementsAnnuels': 18,
},
];
// Filtrage des organisations
List<Map<String, dynamic>> get _filteredOrganisations {
var organisations = _allOrganisations;
// Filtrage par recherche
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
organisations = organisations.where((org) =>
org['nom'].toString().toLowerCase().contains(query) ||
org['description'].toString().toLowerCase().contains(query) ||
org['secteurActivite'].toString().toLowerCase().contains(query) ||
org['type'].toString().toLowerCase().contains(query)).toList();
}
// Le filtrage par type est maintenant géré par les onglets
return organisations;
}
@override
Widget build(BuildContext context) {
return BlocListener<OrganizationsBloc, OrganizationsState>(
listener: (context, state) {
// Gestion des erreurs avec SnackBar
if (state is OrganizationsError) {
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<OrganizationsBloc>().add(const LoadOrganizations());
},
),
),
);
}
},
child: Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header épuré sans statistiques
_buildCleanHeader(),
const SizedBox(height: 16),
// Section statistiques dédiée
_buildStatsSection(),
const SizedBox(height: 16),
// Barre de recherche et filtres
_buildSearchAndFilters(),
const SizedBox(height: 16),
// Onglets de catégories
_buildCategoryTabs(),
const SizedBox(height: 16),
// Liste des organisations
_buildOrganisationsDisplay(),
const SizedBox(height: 80), // Espace pour le FAB
],
),
),
floatingActionButton: _buildActionButton(),
),
);
}
/// Bouton d'action harmonisé
Widget _buildActionButton() {
return FloatingActionButton.extended(
onPressed: () => _showCreateOrganisationDialog(),
backgroundColor: const Color(0xFF6C5CE7),
elevation: 8,
icon: const Icon(Icons.add, color: Colors.white),
label: const Text(
'Nouvelle organisation',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
);
}
/// Header épuré et cohérent avec le design system
Widget _buildCleanHeader() {
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: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.business,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Gestion des Organisations',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'Interface complète de gestion des organisations',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
_buildHeaderActions(),
],
),
);
}
/// Section statistiques dédiée et harmonisée
Widget _buildStatsSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.analytics_outlined,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Statistiques',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatCard(
'Total',
'${_allOrganisations.length}',
Icons.business_outlined,
const Color(0xFF6C5CE7),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'Actives',
'${_allOrganisations.where((o) => o['status'] == 'Active').length}',
Icons.check_circle_outline,
const Color(0xFF00B894),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'Membres',
'${_allOrganisations.fold<int>(0, (sum, o) => sum + (o['nombreMembres'] as int))}',
Icons.people_outline,
const Color(0xFF0984E3),
),
),
],
),
],
),
);
}
/// Actions du header
Widget _buildHeaderActions() {
return Row(
children: [
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
onPressed: () => _showNotifications(),
icon: const Icon(Icons.notifications_outlined, color: Colors.white),
tooltip: 'Notifications',
),
),
const SizedBox(width: 8),
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
onPressed: () => _showSettings(),
icon: const Icon(Icons.settings_outlined, color: Colors.white),
tooltip: 'Paramètres',
),
),
],
);
}
/// Carte de statistique harmonisée
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.1),
width: 1,
),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
),
);
}
/// Onglets de catégories harmonisés
Widget _buildCategoryTabs() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: _tabController,
labelColor: const Color(0xFF6C5CE7),
unselectedLabelColor: Colors.grey[600],
indicatorColor: const Color(0xFF6C5CE7),
indicatorWeight: 3,
indicatorSize: TabBarIndicatorSize.tab,
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
tabs: const [
Tab(text: 'Toutes'),
Tab(text: 'Syndicats'),
Tab(text: 'Fédérations'),
Tab(text: 'Unions'),
],
),
);
}
/// Affichage des organisations harmonisé
Widget _buildOrganisationsDisplay() {
return SizedBox(
height: 600, // Hauteur fixe pour le TabBarView
child: TabBarView(
controller: _tabController,
children: [
_buildOrganisationsTab('Toutes'),
_buildOrganisationsTab('Syndicat'),
_buildOrganisationsTab('Fédération'),
_buildOrganisationsTab('Union'),
],
),
);
}
/// Onglet des organisations
Widget _buildOrganisationsTab(String filter) {
final organisations = filter == 'Toutes'
? _filteredOrganisations
: _filteredOrganisations.where((o) => o['type'] == filter).toList();
return Column(
children: [
// Barre de recherche et filtres
_buildSearchAndFilters(),
// Liste des organisations
Expanded(
child: organisations.isEmpty
? _buildEmptyState()
: _buildOrganisationsList(organisations),
),
],
);
}
/// Barre de recherche et filtres harmonisée
Widget _buildSearchAndFilters() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche
Container(
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey[200]!,
width: 1,
),
),
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
hintText: 'Rechercher par nom, type, secteur...',
hintStyle: TextStyle(
color: Colors.grey[500],
fontSize: 14,
),
prefixIcon: Icon(Icons.search, color: Colors.grey[400]),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
icon: Icon(Icons.clear, color: Colors.grey[400]),
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
],
),
);
}
/// Liste des organisations
Widget _buildOrganisationsList(List<Map<String, dynamic>> organisations) {
return RefreshIndicator(
onRefresh: () async {
// Recharger les organisations
// Note: Cette page utilise des données passées en paramètre
// Le rafraîchissement devrait être géré par le parent
await Future.delayed(const Duration(milliseconds: 500));
},
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: organisations.length,
itemBuilder: (context, index) {
final org = organisations[index];
return _buildOrganisationCard(org);
},
),
);
}
/// Carte d'organisation
Widget _buildOrganisationCard(Map<String, dynamic> org) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: InkWell(
onTap: () => _showOrganisationDetails(org),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF6C5CE7).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.business,
color: Color(0xFF6C5CE7),
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
org['nom'],
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 2),
Text(
org['type'],
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: org['status'] == 'Active' ? Colors.green.withOpacity(0.1) : Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
org['status'],
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: org['status'] == 'Active' ? Colors.green[700] : Colors.orange[700],
),
),
),
],
),
const SizedBox(height: 12),
Text(
org['description'],
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
children: [
_buildInfoChip(Icons.people, '${org['nombreMembres']} membres'),
const SizedBox(width: 8),
_buildInfoChip(Icons.work, org['secteurActivite']),
],
),
],
),
),
),
);
}
/// Chip d'information
Widget _buildInfoChip(IconData icon, String text) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
fontWeight: FontWeight.w500,
),
),
],
),
);
}
/// État vide
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucune organisation trouvée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Essayez de modifier vos critères de recherche',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
// Méthodes temporaires pour éviter les erreurs
void _showNotifications() {}
void _showSettings() {}
void _showOrganisationDetails(Map<String, dynamic> org) {}
void _showCreateOrganisationDialog() {}
}

View File

@@ -0,0 +1,21 @@
/// Wrapper pour la page des organisations avec BLoC Provider
library organisations_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../di/organizations_di.dart';
import '../../bloc/organizations_bloc.dart';
import 'organizations_page.dart';
/// Wrapper qui fournit le BLoC pour la page des organisations
class OrganizationsPageWrapper extends StatelessWidget {
const OrganizationsPageWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<OrganizationsBloc>(
create: (context) => OrganizationsDI.getOrganizationsBloc(),
child: const OrganizationsPage(),
);
}
}

View File

@@ -0,0 +1,403 @@
/// Dialogue de création d'organisation (mutuelle)
/// Formulaire complet pour créer une nouvelle mutuelle
library create_organisation_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/organizations_bloc.dart';
import '../../bloc/organizations_event.dart';
import '../../data/models/organization_model.dart';
/// Dialogue de création d'organisation
class CreateOrganizationDialog extends StatefulWidget {
const CreateOrganizationDialog({super.key});
@override
State<CreateOrganizationDialog> createState() => _CreateOrganizationDialogState();
}
class _CreateOrganizationDialogState extends State<CreateOrganizationDialog> {
final _formKey = GlobalKey<FormState>();
// Contrôleurs de texte
final _nomController = TextEditingController();
final _nomCourtController = TextEditingController();
final _descriptionController = TextEditingController();
final _emailController = TextEditingController();
final _telephoneController = TextEditingController();
final _adresseController = TextEditingController();
final _villeController = TextEditingController();
final _codePostalController = TextEditingController();
final _regionController = TextEditingController();
final _paysController = TextEditingController();
final _siteWebController = TextEditingController();
final _objectifsController = TextEditingController();
// Valeurs sélectionnées
TypeOrganization _selectedType = TypeOrganization.association;
bool _accepteNouveauxMembres = true;
bool _organisationPublique = true;
@override
void dispose() {
_nomController.dispose();
_nomCourtController.dispose();
_descriptionController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_adresseController.dispose();
_villeController.dispose();
_codePostalController.dispose();
_regionController.dispose();
_paysController.dispose();
_siteWebController.dispose();
_objectifsController.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(0xFF8B5CF6),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
child: Row(
children: [
const Icon(Icons.business, color: Colors.white),
const SizedBox(width: 12),
const Text(
'Créer une mutuelle',
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 de base
_buildSectionTitle('Informations de base'),
const SizedBox(height: 12),
TextFormField(
controller: _nomController,
decoration: const InputDecoration(
labelText: 'Nom de la mutuelle *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.business),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le nom est obligatoire';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _nomCourtController,
decoration: const InputDecoration(
labelText: 'Nom court / Sigle',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.short_text),
hintText: 'Ex: MUTEC, MUPROCI',
),
),
const SizedBox(height: 12),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
maxLines: 3,
),
const SizedBox(height: 12),
// Type d'organisation
DropdownButtonFormField<TypeOrganization>(
value: _selectedType,
decoration: const InputDecoration(
labelText: 'Type d\'organisation *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: TypeOrganization.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.displayName),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedType = value!;
});
},
),
const SizedBox(height: 16),
// Contact
_buildSectionTitle('Contact'),
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),
TextFormField(
controller: _siteWebController,
decoration: const InputDecoration(
labelText: 'Site web',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.language),
hintText: 'https://www.exemple.com',
),
keyboardType: TextInputType.url,
),
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),
// Objectifs
_buildSectionTitle('Objectifs et mission'),
const SizedBox(height: 12),
TextFormField(
controller: _objectifsController,
decoration: const InputDecoration(
labelText: 'Objectifs',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.flag),
),
maxLines: 3,
),
const SizedBox(height: 16),
// Paramètres
_buildSectionTitle('Paramètres'),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('Accepte de nouveaux membres'),
subtitle: const Text('Permet l\'adhésion de nouveaux membres'),
value: _accepteNouveauxMembres,
onChanged: (value) {
setState(() {
_accepteNouveauxMembres = value;
});
},
),
SwitchListTile(
title: const Text('Organisation publique'),
subtitle: const Text('Visible dans l\'annuaire public'),
value: _organisationPublique,
onChanged: (value) {
setState(() {
_organisationPublique = value;
});
},
),
],
),
),
),
),
// 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(0xFF8B5CF6),
foregroundColor: Colors.white,
),
child: const Text('Créer la mutuelle'),
),
],
),
),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF8B5CF6),
),
);
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
// Créer le modèle d'organisation
final organisation = OrganizationModel(
nom: _nomController.text,
nomCourt: _nomCourtController.text.isNotEmpty ? _nomCourtController.text : null,
description: _descriptionController.text.isNotEmpty ? _descriptionController.text : null,
email: _emailController.text,
telephone: _telephoneController.text.isNotEmpty ? _telephoneController.text : null,
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,
siteWeb: _siteWebController.text.isNotEmpty ? _siteWebController.text : null,
objectifs: _objectifsController.text.isNotEmpty ? _objectifsController.text : null,
typeOrganisation: _selectedType,
statut: StatutOrganization.active,
accepteNouveauxMembres: _accepteNouveauxMembres,
organisationPublique: _organisationPublique,
);
// Envoyer l'événement au BLoC
context.read<OrganizationsBloc>().add(CreateOrganization(organisation));
// Fermer le dialogue
Navigator.pop(context);
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mutuelle créée avec succès'),
backgroundColor: Colors.green,
),
);
}
}
}

View File

@@ -0,0 +1,485 @@
/// Dialogue de modification d'organisation (mutuelle)
library edit_organisation_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/organizations_bloc.dart';
import '../../bloc/organizations_event.dart';
import '../../data/models/organization_model.dart';
class EditOrganizationDialog extends StatefulWidget {
final OrganizationModel organization;
const EditOrganizationDialog({
super.key,
required this.organization,
});
@override
State<EditOrganizationDialog> createState() => _EditOrganizationDialogState();
}
class _EditOrganizationDialogState extends State<EditOrganizationDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nomController;
late final TextEditingController _nomCourtController;
late final TextEditingController _descriptionController;
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 _siteWebController;
late final TextEditingController _objectifsController;
late TypeOrganization _selectedType;
late StatutOrganization _selectedStatut;
late bool _accepteNouveauxMembres;
late bool _organisationPublique;
@override
void initState() {
super.initState();
_nomController = TextEditingController(text: widget.organization.nom);
_nomCourtController = TextEditingController(text: widget.organization.nomCourt ?? '');
_descriptionController = TextEditingController(text: widget.organization.description ?? '');
_emailController = TextEditingController(text: widget.organization.email);
_telephoneController = TextEditingController(text: widget.organization.telephone ?? '');
_adresseController = TextEditingController(text: widget.organization.adresse ?? '');
_villeController = TextEditingController(text: widget.organization.ville ?? '');
_codePostalController = TextEditingController(text: widget.organization.codePostal ?? '');
_regionController = TextEditingController(text: widget.organization.region ?? '');
_paysController = TextEditingController(text: widget.organization.pays ?? '');
_siteWebController = TextEditingController(text: widget.organization.siteWeb ?? '');
_objectifsController = TextEditingController(text: widget.organization.objectifs ?? '');
_selectedType = widget.organization.typeOrganisation;
_selectedStatut = widget.organization.statut;
_accepteNouveauxMembres = widget.organization.accepteNouveauxMembres;
_organisationPublique = widget.organization.organisationPublique;
}
@override
void dispose() {
_nomController.dispose();
_nomCourtController.dispose();
_descriptionController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_adresseController.dispose();
_villeController.dispose();
_codePostalController.dispose();
_regionController.dispose();
_paysController.dispose();
_siteWebController.dispose();
_objectifsController.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: [
_buildHeader(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('Informations de base'),
const SizedBox(height: 12),
_buildNomField(),
const SizedBox(height: 12),
_buildNomCourtField(),
const SizedBox(height: 12),
_buildDescriptionField(),
const SizedBox(height: 12),
_buildTypeDropdown(),
const SizedBox(height: 12),
_buildStatutDropdown(),
const SizedBox(height: 16),
_buildSectionTitle('Contact'),
const SizedBox(height: 12),
_buildEmailField(),
const SizedBox(height: 12),
_buildTelephoneField(),
const SizedBox(height: 12),
_buildSiteWebField(),
const SizedBox(height: 16),
_buildSectionTitle('Adresse'),
const SizedBox(height: 12),
_buildAdresseField(),
const SizedBox(height: 12),
_buildVilleCodePostalRow(),
const SizedBox(height: 12),
_buildRegionField(),
const SizedBox(height: 12),
_buildPaysField(),
const SizedBox(height: 16),
_buildSectionTitle('Objectifs et mission'),
const SizedBox(height: 12),
_buildObjectifsField(),
const SizedBox(height: 16),
_buildSectionTitle('Paramètres'),
const SizedBox(height: 12),
_buildAccepteNouveauxMembresSwitch(),
_buildOrganisationPubliqueSwitch(),
],
),
),
),
),
_buildActionButtons(),
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Color(0xFF8B5CF6),
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 la mutuelle',
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),
),
],
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF8B5CF6),
),
);
}
Widget _buildNomField() {
return TextFormField(
controller: _nomController,
decoration: const InputDecoration(
labelText: 'Nom de la mutuelle *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.business),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le nom est obligatoire';
}
return null;
},
);
}
Widget _buildNomCourtField() {
return TextFormField(
controller: _nomCourtController,
decoration: const InputDecoration(
labelText: 'Nom court / Sigle',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.short_text),
hintText: 'Ex: MUTEC, MUPROCI',
),
);
}
Widget _buildDescriptionField() {
return TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
maxLines: 3,
);
}
Widget _buildTypeDropdown() {
return DropdownButtonFormField<TypeOrganization>(
value: _selectedType,
decoration: const InputDecoration(
labelText: 'Type d\'organisation *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: TypeOrganization.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.displayName),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedType = value!;
});
},
);
}
Widget _buildStatutDropdown() {
return DropdownButtonFormField<StatutOrganization>(
value: _selectedStatut,
decoration: const InputDecoration(
labelText: 'Statut *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.info),
),
items: StatutOrganization.values.map((statut) {
return DropdownMenuItem(
value: statut,
child: Text(statut.displayName),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedStatut = value!;
});
},
);
}
Widget _buildEmailField() {
return 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;
},
);
}
Widget _buildSiteWebField() {
return TextFormField(
controller: _siteWebController,
decoration: const InputDecoration(
labelText: 'Site web',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.language),
hintText: 'https://www.exemple.com',
),
keyboardType: TextInputType.url,
);
}
Widget _buildAdresseField() {
return TextFormField(
controller: _adresseController,
decoration: const InputDecoration(
labelText: 'Adresse',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.home),
),
maxLines: 2,
);
}
Widget _buildVilleCodePostalRow() {
return 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(),
),
),
),
],
);
}
Widget _buildRegionField() {
return TextFormField(
controller: _regionController,
decoration: const InputDecoration(
labelText: 'Région',
border: OutlineInputBorder(),
),
);
}
Widget _buildPaysField() {
return TextFormField(
controller: _paysController,
decoration: const InputDecoration(
labelText: 'Pays',
border: OutlineInputBorder(),
),
);
}
Widget _buildObjectifsField() {
return TextFormField(
controller: _objectifsController,
decoration: const InputDecoration(
labelText: 'Objectifs',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.flag),
),
maxLines: 3,
);
}
Widget _buildAccepteNouveauxMembresSwitch() {
return SwitchListTile(
title: const Text('Accepte de nouveaux membres'),
subtitle: const Text('Permet l\'adhésion de nouveaux membres'),
value: _accepteNouveauxMembres,
onChanged: (value) {
setState(() {
_accepteNouveauxMembres = value;
});
},
);
}
Widget _buildOrganisationPubliqueSwitch() {
return SwitchListTile(
title: const Text('Organisation publique'),
subtitle: const Text('Visible dans l\'annuaire public'),
value: _organisationPublique,
onChanged: (value) {
setState(() {
_organisationPublique = value;
});
},
);
}
Widget _buildActionButtons() {
return 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(0xFF8B5CF6),
foregroundColor: Colors.white,
),
child: const Text('Enregistrer'),
),
],
),
);
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final updatedOrganisation = widget.organization.copyWith(
nom: _nomController.text,
nomCourt: _nomCourtController.text.isNotEmpty ? _nomCourtController.text : null,
description: _descriptionController.text.isNotEmpty ? _descriptionController.text : null,
email: _emailController.text,
telephone: _telephoneController.text.isNotEmpty ? _telephoneController.text : null,
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,
siteWeb: _siteWebController.text.isNotEmpty ? _siteWebController.text : null,
objectifs: _objectifsController.text.isNotEmpty ? _objectifsController.text : null,
typeOrganisation: _selectedType,
statut: _selectedStatut,
accepteNouveauxMembres: _accepteNouveauxMembres,
organisationPublique: _organisationPublique,
);
context.read<OrganizationsBloc>().add(UpdateOrganization(widget.organization.id!, updatedOrganisation));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Mutuelle modifiée avec succès'),
backgroundColor: Colors.green,
),
);
}
}
Widget _buildTelephoneField() {
return TextFormField(
controller: _telephoneController,
decoration: const InputDecoration(
labelText: 'Téléphone',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
keyboardType: TextInputType.phone,
);
}
}

View File

@@ -0,0 +1,306 @@
/// Widget de carte d'organisation
/// Respecte le design system établi avec les mêmes patterns que les autres cartes
library organization_card;
import 'package:flutter/material.dart';
import '../../data/models/organization_model.dart';
/// Carte d'organisation avec design cohérent
class OrganizationCard extends StatelessWidget {
final OrganizationModel organization;
final VoidCallback? onTap;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
final bool showActions;
const OrganizationCard({
super.key,
required this.organization,
this.onTap,
this.onEdit,
this.onDelete,
this.showActions = true,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12), // SpacingTokens cohérent
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 8),
_buildContent(),
const SizedBox(height: 8),
_buildFooter(),
],
),
),
),
);
}
/// Header avec nom et statut
Widget _buildHeader() {
return Row(
children: [
// Icône du type d'organisation
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF6C5CE7).withOpacity(0.1), // ColorTokens cohérent
borderRadius: BorderRadius.circular(6),
),
child: Text(
organization.typeOrganisation.icon,
style: const TextStyle(fontSize: 16),
),
),
const SizedBox(width: 12),
// Nom et nom court
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
organization.nom,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF374151), // ColorTokens cohérent
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (organization.nomCourt?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
organization.nomCourt!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
],
),
),
// Badge de statut
_buildStatusBadge(),
],
);
}
/// Badge de statut
Widget _buildStatusBadge() {
final color = Color(int.parse(organization.statut.color.substring(1), radix: 16) + 0xFF000000);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
organization.statut.displayName,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
/// Contenu principal
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Type d'organisation
Row(
children: [
const Icon(
Icons.category_outlined,
size: 14,
color: Color(0xFF6B7280),
),
const SizedBox(width: 6),
Text(
organization.typeOrganisation.displayName,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
),
const SizedBox(height: 4),
// Localisation
if (organization.ville?.isNotEmpty == true || organization.region?.isNotEmpty == true)
Row(
children: [
const Icon(
Icons.location_on_outlined,
size: 14,
color: Color(0xFF6B7280),
),
const SizedBox(width: 6),
Expanded(
child: Text(
_buildLocationText(),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
// Description si disponible
if (organization.description?.isNotEmpty == true) ...[
Text(
organization.description!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
],
],
);
}
/// Footer avec statistiques et actions
Widget _buildFooter() {
return Row(
children: [
// Statistiques
Expanded(
child: Row(
children: [
_buildStatItem(
icon: Icons.people_outline,
value: organization.nombreMembres.toString(),
label: 'membres',
),
const SizedBox(width: 16),
if (organization.ancienneteAnnees > 0)
_buildStatItem(
icon: Icons.access_time,
value: organization.ancienneteAnnees.toString(),
label: 'ans',
),
],
),
),
// Actions
if (showActions) _buildActions(),
],
);
}
/// Item de statistique
Widget _buildStatItem({
required IconData icon,
required String value,
required String label,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: const Color(0xFF6C5CE7),
),
const SizedBox(width: 4),
Text(
'$value $label',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF374151),
),
),
],
);
}
/// Actions (éditer, supprimer)
Widget _buildActions() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onEdit != null)
IconButton(
onPressed: onEdit,
icon: const Icon(
Icons.edit_outlined,
size: 18,
color: Color(0xFF6C5CE7),
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
tooltip: 'Modifier',
),
if (onDelete != null)
IconButton(
onPressed: onDelete,
icon: Icon(
Icons.delete_outline,
size: 18,
color: Colors.red.shade400,
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
tooltip: 'Supprimer',
),
],
);
}
/// Construit le texte de localisation
String _buildLocationText() {
final parts = <String>[];
if (organization.ville?.isNotEmpty == true) {
parts.add(organization.ville!);
}
if (organization.region?.isNotEmpty == true) {
parts.add(organization.region!);
}
if (organization.pays?.isNotEmpty == true) {
parts.add(organization.pays!);
}
return parts.join(', ');
}
}

View File

@@ -0,0 +1,301 @@
/// Widget de filtres pour les organisations
/// Respecte le design system établi
library organization_filter_widget;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/organizations_bloc.dart';
import '../../bloc/organizations_event.dart';
import '../../bloc/organizations_state.dart';
import '../../data/models/organization_model.dart';
/// Widget de filtres avec design cohérent
class OrganizationFilterWidget extends StatelessWidget {
const OrganizationFilterWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<OrganizationsBloc, OrganizationsState>(
builder: (context, state) {
if (state is! OrganizationsLoaded) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.filter_list,
size: 16,
color: Color(0xFF6C5CE7),
),
const SizedBox(width: 6),
const Text(
'Filtres',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF374151),
),
),
const Spacer(),
if (state.hasFilters)
TextButton(
onPressed: () {
context.read<OrganizationsBloc>().add(
const ClearOrganizationsFilters(),
);
},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'Effacer',
style: TextStyle(
fontSize: 12,
color: Color(0xFF6C5CE7),
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatusFilter(context, state),
),
const SizedBox(width: 8),
Expanded(
child: _buildTypeFilter(context, state),
),
],
),
const SizedBox(height: 8),
_buildSortOptions(context, state),
],
),
);
},
);
}
/// Filtre par statut
Widget _buildStatusFilter(BuildContext context, OrganizationsLoaded state) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: const Color(0xFFE5E7EB),
width: 1,
),
borderRadius: BorderRadius.circular(6),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<StatutOrganization?>(
value: state.statusFilter,
hint: const Text(
'Statut',
style: TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
isExpanded: true,
padding: const EdgeInsets.symmetric(horizontal: 8),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF374151),
),
items: [
const DropdownMenuItem<StatutOrganization?>(
value: null,
child: Text('Tous les statuts'),
),
...StatutOrganization.values.map((statut) {
return DropdownMenuItem<StatutOrganization?>(
value: statut,
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Color(int.parse(statut.color.substring(1), radix: 16) + 0xFF000000),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(statut.displayName),
],
),
);
}),
],
onChanged: (value) {
context.read<OrganizationsBloc>().add(
FilterOrganizationsByStatus(value),
);
},
),
),
);
}
/// Filtre par type
Widget _buildTypeFilter(BuildContext context, OrganizationsLoaded state) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: const Color(0xFFE5E7EB),
width: 1,
),
borderRadius: BorderRadius.circular(6),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<TypeOrganization?>(
value: state.typeFilter,
hint: const Text(
'Type',
style: TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
isExpanded: true,
padding: const EdgeInsets.symmetric(horizontal: 8),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF374151),
),
items: [
const DropdownMenuItem<TypeOrganization?>(
value: null,
child: Text('Tous les types'),
),
...TypeOrganization.values.map((type) {
return DropdownMenuItem<TypeOrganization?>(
value: type,
child: Row(
children: [
Text(
type.icon,
style: const TextStyle(fontSize: 12),
),
const SizedBox(width: 6),
Expanded(
child: Text(
type.displayName,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}),
],
onChanged: (value) {
context.read<OrganizationsBloc>().add(
FilterOrganizationsByType(value),
);
},
),
),
);
}
/// Options de tri
Widget _buildSortOptions(BuildContext context, OrganizationsLoaded state) {
return Row(
children: [
const Icon(
Icons.sort,
size: 14,
color: Color(0xFF6B7280),
),
const SizedBox(width: 6),
const Text(
'Trier par:',
style: TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
const SizedBox(width: 8),
Expanded(
child: Wrap(
spacing: 4,
children: OrganizationSortType.values.map((sortType) {
final isSelected = state.sortType == sortType;
return InkWell(
onTap: () {
final ascending = isSelected ? !state.sortAscending : true;
context.read<OrganizationsBloc>().add(
SortOrganizations(sortType, ascending: ascending),
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF6C5CE7).withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? const Color(0xFF6C5CE7)
: const Color(0xFFE5E7EB),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
sortType.displayName,
style: TextStyle(
fontSize: 10,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? const Color(0xFF6C5CE7)
: const Color(0xFF6B7280),
),
),
if (isSelected) ...[
const SizedBox(width: 2),
Icon(
state.sortAscending
? Icons.arrow_upward
: Icons.arrow_downward,
size: 10,
color: const Color(0xFF6C5CE7),
),
],
],
),
),
);
}).toList(),
),
),
],
);
}
}

View File

@@ -0,0 +1,113 @@
/// Widget de barre de recherche pour les organisations
/// Respecte le design system établi
library organisation_search_bar;
import 'package:flutter/material.dart';
/// Barre de recherche avec design cohérent
class OrganisationSearchBar extends StatefulWidget {
final TextEditingController controller;
final Function(String) onSearch;
final VoidCallback? onClear;
final String hintText;
final bool enabled;
const OrganisationSearchBar({
super.key,
required this.controller,
required this.onSearch,
this.onClear,
this.hintText = 'Rechercher une organisation...',
this.enabled = true,
});
@override
State<OrganisationSearchBar> createState() => _OrganisationSearchBarState();
}
class _OrganisationSearchBarState extends State<OrganisationSearchBar> {
bool _hasText = false;
@override
void initState() {
super.initState();
widget.controller.addListener(_onTextChanged);
_hasText = widget.controller.text.isNotEmpty;
}
@override
void dispose() {
widget.controller.removeListener(_onTextChanged);
super.dispose();
}
void _onTextChanged() {
final hasText = widget.controller.text.isNotEmpty;
if (hasText != _hasText) {
setState(() {
_hasText = hasText;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: TextField(
controller: widget.controller,
enabled: widget.enabled,
onChanged: widget.onSearch,
onSubmitted: widget.onSearch,
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: const TextStyle(
color: Color(0xFF6B7280),
fontSize: 14,
),
prefixIcon: const Icon(
Icons.search,
color: Color(0xFF6C5CE7), // ColorTokens cohérent
size: 20,
),
suffixIcon: _hasText
? IconButton(
onPressed: () {
widget.controller.clear();
widget.onClear?.call();
},
icon: const Icon(
Icons.clear,
color: Color(0xFF6B7280),
size: 20,
),
tooltip: 'Effacer',
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
style: const TextStyle(
fontSize: 14,
color: Color(0xFF374151),
),
),
),
);
}
}

View File

@@ -0,0 +1,160 @@
/// Widget des statistiques des organisations
/// Respecte le design system avec les mêmes patterns que les autres stats
library organisation_stats_widget;
import 'package:flutter/material.dart';
/// Widget des statistiques avec design cohérent
class OrganisationStatsWidget extends StatelessWidget {
final Map<String, int> stats;
final Function(String)? onStatTap;
const OrganisationStatsWidget({
super.key,
required this.stats,
this.onStatTap,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8), // RadiusTokens cohérent
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statistiques',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6C5CE7), // ColorTokens cohérent
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatCard(
title: 'Total',
value: stats['total']?.toString() ?? '0',
icon: Icons.business,
color: const Color(0xFF6C5CE7),
onTap: () => onStatTap?.call('total'),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildStatCard(
title: 'Actives',
value: stats['actives']?.toString() ?? '0',
icon: Icons.check_circle,
color: const Color(0xFF10B981),
onTap: () => onStatTap?.call('actives'),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildStatCard(
title: 'Inactives',
value: stats['inactives']?.toString() ?? '0',
icon: Icons.pause_circle,
color: const Color(0xFF6B7280),
onTap: () => onStatTap?.call('inactives'),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildStatCard(
title: 'Membres',
value: stats['totalMembres']?.toString() ?? '0',
icon: Icons.people,
color: const Color(0xFF3B82F6),
onTap: () => onStatTap?.call('membres'),
),
),
],
),
],
),
);
}
/// Carte de statistique individuelle
Widget _buildStatCard({
required String title,
required String value,
required IconData icon,
required Color color,
VoidCallback? onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: color.withOpacity(0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 16,
color: color,
),
const SizedBox(width: 6),
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: color,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
),
);
}
}