Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
586
lib/features/organizations/bloc/organizations_bloc.dart
Normal file
586
lib/features/organizations/bloc/organizations_bloc.dart
Normal file
@@ -0,0 +1,586 @@
|
||||
/// BLoC pour la gestion des organisations (Clean Architecture)
|
||||
library organizations_bloc;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../data/models/organization_model.dart';
|
||||
import '../data/services/organization_service.dart';
|
||||
import '../domain/usecases/get_organizations.dart';
|
||||
import '../domain/usecases/get_organization_by_id.dart';
|
||||
import '../domain/usecases/create_organization.dart' as uc;
|
||||
import '../domain/usecases/update_organization.dart' as uc;
|
||||
import '../domain/usecases/delete_organization.dart' as uc;
|
||||
import '../domain/usecases/get_organization_members.dart';
|
||||
import '../domain/usecases/update_organization_config.dart';
|
||||
import '../domain/repositories/organization_repository.dart';
|
||||
import 'organizations_event.dart';
|
||||
import 'organizations_state.dart';
|
||||
|
||||
/// BLoC principal pour la gestion des organisations
|
||||
@injectable
|
||||
class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
|
||||
final GetOrganizations _getOrganizations;
|
||||
final GetOrganizationById _getOrganizationById;
|
||||
final uc.CreateOrganization _createOrganization;
|
||||
final uc.UpdateOrganization _updateOrganization;
|
||||
final uc.DeleteOrganization _deleteOrganization;
|
||||
final GetOrganizationMembers _getOrganizationMembers;
|
||||
final UpdateOrganizationConfig _updateOrganizationConfig;
|
||||
final IOrganizationRepository _repository; // Pour méthodes non-couvertes (activate, suspend, search, stats)
|
||||
final OrganizationService _organizationService; // Pour helpers (sort, filter local)
|
||||
|
||||
OrganizationsBloc(
|
||||
this._getOrganizations,
|
||||
this._getOrganizationById,
|
||||
this._createOrganization,
|
||||
this._updateOrganization,
|
||||
this._deleteOrganization,
|
||||
this._getOrganizationMembers,
|
||||
this._updateOrganizationConfig,
|
||||
this._repository,
|
||||
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<SuspendOrganization>(_onSuspendOrganization);
|
||||
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());
|
||||
}
|
||||
|
||||
List<OrganizationModel> listToUse;
|
||||
List<String>? filterIds;
|
||||
if (event.useMesOnly) {
|
||||
// Admin d'organisation : endpoint backend /mes retourne uniquement ses organisations
|
||||
listToUse = await _repository.getMesOrganisations();
|
||||
filterIds = listToUse.map((o) => o.id).whereType<String>().toList();
|
||||
} else {
|
||||
final organizations = await _getOrganizations(
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
recherche: event.recherche,
|
||||
);
|
||||
if (event.filterOrganizationIds != null &&
|
||||
event.filterOrganizationIds!.isNotEmpty) {
|
||||
final allowedIds = event.filterOrganizationIds!.toSet();
|
||||
listToUse = organizations
|
||||
.where((org) => org.id != null && allowedIds.contains(org.id))
|
||||
.toList();
|
||||
filterIds = event.filterOrganizationIds;
|
||||
} else {
|
||||
listToUse = organizations;
|
||||
filterIds = null;
|
||||
}
|
||||
}
|
||||
|
||||
emit(OrganizationsLoaded(
|
||||
organizations: listToUse,
|
||||
filteredOrganizations: listToUse,
|
||||
hasReachedMax: event.useMesOnly || listToUse.length < event.size,
|
||||
currentPage: event.page,
|
||||
currentSearch: event.recherche,
|
||||
filterOrganizationIds: filterIds,
|
||||
useMesOnly: event.useMesOnly,
|
||||
));
|
||||
} 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 _getOrganizations(
|
||||
page: nextPage,
|
||||
size: 20,
|
||||
recherche: currentState.currentSearch,
|
||||
);
|
||||
|
||||
var allOrganizations = [...currentState.organizations, ...newOrganizations];
|
||||
// Réappliquer le filtre orgAdmin si présent
|
||||
if (currentState.filterOrganizationIds != null &&
|
||||
currentState.filterOrganizationIds!.isNotEmpty) {
|
||||
final allowedIds = currentState.filterOrganizationIds!.toSet();
|
||||
allOrganizations = allOrganizations
|
||||
.where((org) => org.id != null && allowedIds.contains(org.id))
|
||||
.toList();
|
||||
}
|
||||
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 _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 _repository.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 _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 _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 _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 _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 _repository.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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspend une organisation
|
||||
Future<void> _onSuspendOrganization(
|
||||
SuspendOrganization event,
|
||||
Emitter<OrganizationsState> emit,
|
||||
) async {
|
||||
emit(OrganizationSuspending(event.id));
|
||||
|
||||
try {
|
||||
final suspendedOrganization = await _repository.suspendOrganization(event.id);
|
||||
emit(OrganizationSuspended(suspendedOrganization));
|
||||
|
||||
// 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 ? suspendedOrganization : org;
|
||||
}).toList();
|
||||
|
||||
final filteredList = _applyCurrentFilters(updatedList, currentState);
|
||||
emit(currentState.copyWith(
|
||||
organizations: updatedList,
|
||||
filteredOrganizations: filteredList,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(OrganizationsError(
|
||||
'Erreur lors de la suspension 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 _repository.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,
|
||||
) {
|
||||
final currentState = state;
|
||||
if (currentState is OrganizationsLoaded && currentState.useMesOnly) {
|
||||
add(const LoadOrganizations(refresh: true, useMesOnly: true));
|
||||
} else {
|
||||
final filterIds = currentState is OrganizationsLoaded
|
||||
? currentState.filterOrganizationIds
|
||||
: null;
|
||||
add(LoadOrganizations(refresh: true, filterOrganizationIds: filterIds));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
192
lib/features/organizations/bloc/organizations_event.dart
Normal file
192
lib/features/organizations/bloc/organizations_event.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
/// É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;
|
||||
/// Filtre par IDs d'organisations (ex. pour orgAdmin : uniquement son organisation)
|
||||
final List<String>? filterOrganizationIds;
|
||||
/// Si true, appelle GET /api/organisations/mes (uniquement les orgs du membre connecté)
|
||||
final bool useMesOnly;
|
||||
|
||||
const LoadOrganizations({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
this.recherche,
|
||||
this.refresh = false,
|
||||
this.filterOrganizationIds,
|
||||
this.useMesOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size, recherche, refresh, filterOrganizationIds, useMesOnly];
|
||||
}
|
||||
|
||||
/// É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 suspendre une organisation
|
||||
class SuspendOrganization extends OrganizationsEvent {
|
||||
final String id;
|
||||
|
||||
const SuspendOrganization(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();
|
||||
}
|
||||
|
||||
|
||||
313
lib/features/organizations/bloc/organizations_state.dart
Normal file
313
lib/features/organizations/bloc/organizations_state.dart
Normal file
@@ -0,0 +1,313 @@
|
||||
/// É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;
|
||||
/// Filtre appliqué (ex. orgAdmin : uniquement ses organisations)
|
||||
final List<String>? filterOrganizationIds;
|
||||
/// True si la liste provient de GET /mes (admin d'organisation)
|
||||
final bool useMesOnly;
|
||||
|
||||
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,
|
||||
this.filterOrganizationIds,
|
||||
this.useMesOnly = false,
|
||||
});
|
||||
|
||||
/// 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,
|
||||
List<String>? filterOrganizationIds,
|
||||
bool? useMesOnly,
|
||||
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,
|
||||
filterOrganizationIds: filterOrganizationIds ?? this.filterOrganizationIds,
|
||||
useMesOnly: useMesOnly ?? this.useMesOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
filterOrganizationIds,
|
||||
useMesOnly,
|
||||
];
|
||||
}
|
||||
|
||||
/// É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 suspension d'organisation
|
||||
class OrganizationSuspending extends OrganizationsState {
|
||||
final String id;
|
||||
|
||||
const OrganizationSuspending(this.id);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// État de succès de suspension
|
||||
class OrganizationSuspended extends OrganizationsState {
|
||||
final OrganizationModel organization;
|
||||
|
||||
const OrganizationSuspended(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];
|
||||
}
|
||||
442
lib/features/organizations/data/models/organization_model.dart
Normal file
442
lib/features/organizations/data/models/organization_model.dart
Normal file
@@ -0,0 +1,442 @@
|
||||
/// 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;
|
||||
|
||||
/// Nombre d'événements (fourni par l'API si disponible)
|
||||
@JsonKey(name: 'nombreEvenements')
|
||||
final int? nombreEvenements;
|
||||
|
||||
/// 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.nombreEvenements,
|
||||
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,
|
||||
int? nombreEvenements,
|
||||
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,
|
||||
nombreEvenements: nombreEvenements ?? this.nombreEvenements,
|
||||
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,
|
||||
nombreEvenements,
|
||||
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)';
|
||||
}
|
||||
112
lib/features/organizations/data/models/organization_model.g.dart
Normal file
112
lib/features/organizations/data/models/organization_model.g.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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,
|
||||
nombreEvenements: (json['nombreEvenements'] as num?)?.toInt(),
|
||||
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,
|
||||
'nombreEvenements': instance.nombreEvenements,
|
||||
'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',
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
/// Repository pour la gestion des organisations
|
||||
/// Interface avec l'API backend OrganizationResource
|
||||
library organization_repository_impl;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
|
||||
import '../../domain/repositories/organization_repository.dart';
|
||||
import '../models/organization_model.dart';
|
||||
|
||||
/// Implémentation du repository des organisations
|
||||
@LazySingleton(as: IOrganizationRepository)
|
||||
class OrganizationRepositoryImpl implements IOrganizationRepository {
|
||||
final ApiClient _apiClient;
|
||||
static const String _baseUrl = '/api/organisations';
|
||||
|
||||
OrganizationRepositoryImpl(this._apiClient);
|
||||
|
||||
@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 _apiClient.get(
|
||||
_baseUrl,
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Le backend retourne directement une liste [...]
|
||||
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) {
|
||||
throw Exception('Erreur réseau lors de la récupération des organisations: ${e.message}');
|
||||
} catch (e) {
|
||||
throw Exception('Erreur inattendue lors de la récupération des organisations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrganizationModel>> getMesOrganisations() async {
|
||||
try {
|
||||
const String path = '$_baseUrl/mes';
|
||||
final response = await _apiClient.get(path);
|
||||
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();
|
||||
}
|
||||
throw Exception('Erreur lors de la récupération de mes organisations: ${response.statusCode}');
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Erreur réseau lors de la récupération de mes organisations: ${e.message}');
|
||||
} catch (e) {
|
||||
throw Exception('Erreur inattendue lors de la récupération de mes organisations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrganizationModel?> getOrganizationById(String id) async {
|
||||
try {
|
||||
final response = await _apiClient.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 _apiClient.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 _apiClient.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 _apiClient.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 _apiClient.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<OrganizationModel> suspendOrganization(String id) async {
|
||||
try {
|
||||
final response = await _apiClient.post('$_baseUrl/$id/suspendre');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return OrganizationModel.fromJson(response.data as Map<String, dynamic>);
|
||||
} else {
|
||||
throw Exception('Erreur lors de la suspension 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 la suspension de l\'organisation: ${e.message}');
|
||||
} catch (e) {
|
||||
throw Exception('Erreur inattendue lors de la suspension 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 _apiClient.get(
|
||||
'$_baseUrl/recherche',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Le backend retourne directement une liste [...]
|
||||
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<List<Map<String, dynamic>>> getOrganizationMembers(String organizationId) async {
|
||||
try {
|
||||
final response = await _apiClient.get('$_baseUrl/$organizationId/membres');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data as List<dynamic>;
|
||||
return data.map((e) => Map<String, dynamic>.from(e as Map)).toList();
|
||||
} else {
|
||||
throw Exception('Erreur lors de la récupération des membres: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Organisation non trouvée');
|
||||
}
|
||||
throw Exception('Erreur réseau lors de la récupération des membres: ${e.message}');
|
||||
} catch (e) {
|
||||
throw Exception('Erreur inattendue lors de la récupération des membres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OrganizationModel> updateOrganizationConfig(
|
||||
String id,
|
||||
Map<String, dynamic> config,
|
||||
) async {
|
||||
try {
|
||||
final response = await _apiClient.put(
|
||||
'$_baseUrl/$id/configuration',
|
||||
data: config,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return OrganizationModel.fromJson(response.data as Map<String, dynamic>);
|
||||
} else {
|
||||
throw Exception('Erreur lors de la mise à jour de la configuration: ${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('Configuration invalide: ${errorData['error']}');
|
||||
}
|
||||
}
|
||||
throw Exception('Erreur réseau lors de la mise à jour de la configuration: ${e.message}');
|
||||
} catch (e) {
|
||||
throw Exception('Erreur inattendue lors de la mise à jour de la configuration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getOrganizationsStats() async {
|
||||
try {
|
||||
final response = await _apiClient.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/// Service pour la gestion des organisations
|
||||
/// Helpers pour tri, filtrage local et recherche
|
||||
library organization_service;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/organization_model.dart';
|
||||
import '../../domain/repositories/organization_repository.dart';
|
||||
|
||||
/// Service de gestion des organisations (helpers uniquement)
|
||||
@injectable
|
||||
class OrganizationService {
|
||||
final IOrganizationRepository _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 les organisations du membre connecté (pour admin d'organisation)
|
||||
Future<List<OrganizationModel>> getMesOrganisations() async {
|
||||
try {
|
||||
return await _repository.getMesOrganisations();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération de mes 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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspend une organisation
|
||||
Future<OrganizationModel> suspendOrganization(String id) async {
|
||||
if (id.isEmpty) {
|
||||
throw ArgumentError('L\'ID de l\'organisation ne peut pas être vide');
|
||||
}
|
||||
|
||||
try {
|
||||
return await _repository.suspendOrganization(id);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suspension 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/// Interface du repository des organisations (Clean Architecture - Domain Layer)
|
||||
library organization_repository;
|
||||
|
||||
import '../../data/models/organization_model.dart';
|
||||
|
||||
/// Interface du repository pour la gestion des organisations
|
||||
/// Contrat défini dans la couche Domain, implémenté dans la couche Data
|
||||
abstract class IOrganizationRepository {
|
||||
/// Récupère la liste des organisations avec pagination
|
||||
Future<List<OrganizationModel>> getOrganizations({
|
||||
int page = 0,
|
||||
int size = 20,
|
||||
String? recherche,
|
||||
});
|
||||
|
||||
/// Récupère les organisations du membre connecté (pour OrgAdmin)
|
||||
Future<List<OrganizationModel>> getMesOrganisations();
|
||||
|
||||
/// Récupère une organisation par son ID
|
||||
Future<OrganizationModel?> getOrganizationById(String id);
|
||||
|
||||
/// Crée une nouvelle organisation (SuperAdmin)
|
||||
Future<OrganizationModel> createOrganization(OrganizationModel organization);
|
||||
|
||||
/// Met à jour une organisation (OrgAdmin)
|
||||
Future<OrganizationModel> updateOrganization(String id, OrganizationModel organization);
|
||||
|
||||
/// Supprime une organisation (SuperAdmin)
|
||||
Future<void> deleteOrganization(String id);
|
||||
|
||||
/// Active une organisation
|
||||
Future<OrganizationModel> activateOrganization(String id);
|
||||
|
||||
/// Suspend une organisation
|
||||
Future<OrganizationModel> suspendOrganization(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 membres d'une organisation (GET /api/organisations/{id}/membres)
|
||||
Future<List<Map<String, dynamic>>> getOrganizationMembers(String organizationId);
|
||||
|
||||
/// Met à jour la configuration d'une organisation (PUT /api/organisations/{id}/configuration)
|
||||
/// Configuration: logo, couleurs, préférences, modules activés, etc.
|
||||
Future<OrganizationModel> updateOrganizationConfig(
|
||||
String id,
|
||||
Map<String, dynamic> config,
|
||||
);
|
||||
|
||||
/// Récupère les statistiques des organisations
|
||||
Future<Map<String, dynamic>> getOrganizationsStats();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// Use Case: Créer une nouvelle organisation
|
||||
library create_organization;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../data/models/organization_model.dart';
|
||||
import '../repositories/organization_repository.dart';
|
||||
|
||||
/// Crée une nouvelle organisation (SuperAdmin uniquement)
|
||||
@injectable
|
||||
class CreateOrganization {
|
||||
final IOrganizationRepository _repository;
|
||||
|
||||
CreateOrganization(this._repository);
|
||||
|
||||
/// Exécute le use case
|
||||
/// [organization] : Modèle de l'organisation à créer
|
||||
/// Retourne l'organisation créée avec son ID
|
||||
/// Lève une exception en cas d'erreur (données invalides, conflit)
|
||||
Future<OrganizationModel> call(OrganizationModel organization) async {
|
||||
return _repository.createOrganization(organization);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Use Case: Supprimer une organisation
|
||||
library delete_organization;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../repositories/organization_repository.dart';
|
||||
|
||||
/// Supprime une organisation (SuperAdmin uniquement)
|
||||
@injectable
|
||||
class DeleteOrganization {
|
||||
final IOrganizationRepository _repository;
|
||||
|
||||
DeleteOrganization(this._repository);
|
||||
|
||||
/// Exécute le use case
|
||||
/// [id] : Identifiant de l'organisation à supprimer
|
||||
/// Lève une exception si organisation non trouvée ou suppression impossible
|
||||
/// Note: Peut être un soft delete selon l'implémentation backend
|
||||
Future<void> call(String id) async {
|
||||
return _repository.deleteOrganization(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Use Case: Récupérer une organisation par ID
|
||||
library get_organization_by_id;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../data/models/organization_model.dart';
|
||||
import '../repositories/organization_repository.dart';
|
||||
|
||||
/// Récupère le détail d'une organisation par son identifiant
|
||||
@injectable
|
||||
class GetOrganizationById {
|
||||
final IOrganizationRepository _repository;
|
||||
|
||||
GetOrganizationById(this._repository);
|
||||
|
||||
/// Exécute le use case
|
||||
/// [id] : Identifiant de l'organisation
|
||||
/// Retourne l'organisation ou null si non trouvée
|
||||
Future<OrganizationModel?> call(String id) async {
|
||||
return _repository.getOrganizationById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Use Case: Récupérer les membres d'une organisation
|
||||
library get_organization_members;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../repositories/organization_repository.dart';
|
||||
|
||||
/// Récupère la liste des membres d'une organisation
|
||||
@injectable
|
||||
class GetOrganizationMembers {
|
||||
final IOrganizationRepository _repository;
|
||||
|
||||
GetOrganizationMembers(this._repository);
|
||||
|
||||
/// Exécute le use case
|
||||
/// [organizationId] : Identifiant de l'organisation
|
||||
/// Retourne une liste de membres (Map avec id, nom, prenom, role, etc.)
|
||||
/// Endpoint: GET /api/organisations/{id}/membres
|
||||
Future<List<Map<String, dynamic>>> call(String organizationId) async {
|
||||
return _repository.getOrganizationMembers(organizationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/// Use Case: Récupérer la liste des organisations
|
||||
library get_organizations;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../data/models/organization_model.dart';
|
||||
import '../repositories/organization_repository.dart';
|
||||
|
||||
/// Récupère la liste paginée des organisations
|
||||
@injectable
|
||||
class GetOrganizations {
|
||||
final IOrganizationRepository _repository;
|
||||
|
||||
GetOrganizations(this._repository);
|
||||
|
||||
/// Exécute le use case
|
||||
/// [page] : Numéro de page (défaut: 0)
|
||||
/// [size] : Taille de la page (défaut: 20)
|
||||
/// [recherche] : Terme de recherche optionnel
|
||||
/// Retourne une liste d'organisations
|
||||
Future<List<OrganizationModel>> call({
|
||||
int page = 0,
|
||||
int size = 20,
|
||||
String? recherche,
|
||||
}) async {
|
||||
return _repository.getOrganizations(
|
||||
page: page,
|
||||
size: size,
|
||||
recherche: recherche,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Use Case: Mettre à jour une organisation
|
||||
library update_organization;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../data/models/organization_model.dart';
|
||||
import '../repositories/organization_repository.dart';
|
||||
|
||||
/// Met à jour une organisation existante (OrgAdmin ou SuperAdmin)
|
||||
@injectable
|
||||
class UpdateOrganization {
|
||||
final IOrganizationRepository _repository;
|
||||
|
||||
UpdateOrganization(this._repository);
|
||||
|
||||
/// Exécute le use case
|
||||
/// [id] : Identifiant de l'organisation
|
||||
/// [organization] : Modèle avec les données mises à jour
|
||||
/// Retourne l'organisation mise à jour
|
||||
/// Lève une exception si organisation non trouvée ou données invalides
|
||||
Future<OrganizationModel> call(String id, OrganizationModel organization) async {
|
||||
return _repository.updateOrganization(id, organization);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// Use Case: Mettre à jour la configuration d'une organisation
|
||||
library update_organization_config;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../data/models/organization_model.dart';
|
||||
import '../repositories/organization_repository.dart';
|
||||
|
||||
/// Met à jour la configuration spécifique d'une organisation
|
||||
/// (logo, couleurs, modules activés, préférences, etc.)
|
||||
@injectable
|
||||
class UpdateOrganizationConfig {
|
||||
final IOrganizationRepository _repository;
|
||||
|
||||
UpdateOrganizationConfig(this._repository);
|
||||
|
||||
/// Exécute le use case
|
||||
/// [id] : Identifiant de l'organisation
|
||||
/// [config] : Configuration à mettre à jour
|
||||
/// Exemple: { "logo": "url", "couleurPrimaire": "#FF5733", "modulesActifs": ["finance", "events"] }
|
||||
/// Retourne l'organisation avec la configuration mise à jour
|
||||
/// Endpoint: PUT /api/organisations/{id}/configuration
|
||||
Future<OrganizationModel> call(String id, Map<String, dynamic> config) async {
|
||||
return _repository.updateOrganizationConfig(id, config);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
/// 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 'package:url_launcher/url_launcher.dart';
|
||||
import '../../data/models/organization_model.dart';
|
||||
import '../../bloc/organizations_bloc.dart';
|
||||
import '../../bloc/organizations_event.dart';
|
||||
import '../../bloc/organizations_state.dart';
|
||||
import 'edit_organization_page.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: (organization.nombreEvenements ?? 0).toString(),
|
||||
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(organization),
|
||||
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':
|
||||
context.read<OrganizationsBloc>().add(SuspendOrganization(widget.organizationId));
|
||||
break;
|
||||
case 'delete':
|
||||
_showDeleteConfirmation(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre la page d'édition ou le dialog selon le contexte
|
||||
void _showEditDialog([OrganizationModel? organization]) {
|
||||
if (organization == null) {
|
||||
final state = context.read<OrganizationsBloc>().state;
|
||||
if (state is OrganizationLoaded) {
|
||||
organization = state.organization;
|
||||
}
|
||||
}
|
||||
if (organization == null || !context.mounted) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Chargement de l\'organisation en cours...')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final org = organization;
|
||||
final bloc = context.read<OrganizationsBloc>();
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: bloc,
|
||||
child: EditOrganizationPage(organization: org),
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
if (context.mounted) {
|
||||
bloc.add(LoadOrganizationById(widget.organizationId));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
Future<void> _launchEmail(String email) async {
|
||||
final uri = Uri(scheme: 'mailto', path: email);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Impossible d\'ouvrir l\'email: $email')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lance l'application téléphone
|
||||
Future<void> _launchPhone(String phone) async {
|
||||
final uri = Uri(scheme: 'tel', path: phone);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Impossible d\'appeler: $phone')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lance le navigateur web
|
||||
Future<void> _launchWebsite(String url) async {
|
||||
final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url');
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Impossible d\'ouvrir: $url')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
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';
|
||||
import '../widgets/organization_card.dart';
|
||||
import '../widgets/create_organization_dialog.dart';
|
||||
import '../widgets/edit_organization_dialog.dart';
|
||||
import 'organization_detail_page.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_v2.dart';
|
||||
import '../../../../shared/design_system/components/animated_fade_in.dart';
|
||||
import '../../../../shared/design_system/components/animated_slide_in.dart';
|
||||
import '../../../../shared/design_system/components/african_pattern_background.dart';
|
||||
import '../../../../shared/design_system/components/uf_app_bar.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.
|
||||
///
|
||||
/// **Design System V2** - Utilise UnionFlowColors et composants standardisés
|
||||
/// **Backend connecté** - Toutes les données proviennent d'OrganizationsBloc
|
||||
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();
|
||||
TabController? _tabController;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
List<TypeOrganization?> _availableTypes = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
// Les organisations sont déjà chargées par OrganizationsPageWrapper
|
||||
// Le TabController sera initialisé dans didChangeDependencies
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController?.dispose();
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent * 0.9) {
|
||||
// Charger plus d'organisations quand on approche du bas
|
||||
context.read<OrganizationsBloc>().add(const LoadMoreOrganizations());
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les types d'organisations disponibles dans les données
|
||||
List<TypeOrganization?> _calculateAvailableTypes(List<OrganizationModel> organizations) {
|
||||
if (organizations.isEmpty) {
|
||||
return [null]; // Seulement "Toutes"
|
||||
}
|
||||
|
||||
// Extraire tous les types uniques
|
||||
final typesSet = organizations.map((org) => org.typeOrganisation).toSet();
|
||||
final types = typesSet.toList()..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
|
||||
// null en premier pour "Toutes", puis les types triés alphabétiquement
|
||||
return [null, ...types];
|
||||
}
|
||||
|
||||
/// Initialise ou met à jour le TabController si les types ont changé
|
||||
void _updateTabController(List<TypeOrganization?> newTypes) {
|
||||
if (_availableTypes.length != newTypes.length ||
|
||||
!_availableTypes.every((type) => newTypes.contains(type))) {
|
||||
_availableTypes = newTypes;
|
||||
_tabController?.dispose();
|
||||
_tabController = TabController(length: _availableTypes.length, vsync: this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<OrganizationsBloc, OrganizationsState>(
|
||||
listener: (context, state) {
|
||||
// Gestion des messages de succès et erreurs
|
||||
if (state is OrganizationsError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: UnionFlowColors.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
textColor: Colors.white,
|
||||
onPressed: () {
|
||||
context.read<OrganizationsBloc>().add(const LoadOrganizations(refresh: true));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (state is OrganizationCreated) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Organisation créée avec succès'),
|
||||
backgroundColor: UnionFlowColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} else if (state is OrganizationUpdated) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Organisation mise à jour avec succès'),
|
||||
backgroundColor: UnionFlowColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} else if (state is OrganizationDeleted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Organisation supprimée avec succès'),
|
||||
backgroundColor: UnionFlowColors.success,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return AfricanPatternBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: UFAppBar(
|
||||
title: 'Gestion des Organisations',
|
||||
backgroundColor: UnionFlowColors.surface,
|
||||
foregroundColor: UnionFlowColors.textPrimary,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
context.read<OrganizationsBloc>().add(const RefreshOrganizations());
|
||||
},
|
||||
tooltip: 'Rafraîchir',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: _buildBody(state),
|
||||
),
|
||||
floatingActionButton: _buildActionButton(state),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(OrganizationsState state) {
|
||||
if (state is OrganizationsInitial || state is OrganizationsLoading) {
|
||||
return _buildLoadingState();
|
||||
}
|
||||
|
||||
if (state is OrganizationsLoaded) {
|
||||
final loadedState = state;
|
||||
|
||||
// Calculer les types disponibles et mettre à jour le TabController
|
||||
final availableTypes = _calculateAvailableTypes(loadedState.organizations);
|
||||
_updateTabController(availableTypes);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<OrganizationsBloc>().add(const RefreshOrganizations());
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: AnimatedFadeIn(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header avec design system
|
||||
AnimatedSlideIn(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOut,
|
||||
child: _buildHeader(loadedState),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Section statistiques
|
||||
AnimatedSlideIn(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOut,
|
||||
child: _buildStatsSection(loadedState),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Barre de recherche
|
||||
AnimatedSlideIn(
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOut,
|
||||
child: _buildSearchBar(loadedState),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Onglets de catégories dynamiques
|
||||
AnimatedSlideIn(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeOut,
|
||||
child: _buildCategoryTabs(availableTypes),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Liste des organisations
|
||||
AnimatedSlideIn(
|
||||
duration: const Duration(milliseconds: 900),
|
||||
curve: Curves.easeOut,
|
||||
child: _buildOrganizationsList(loadedState),
|
||||
),
|
||||
|
||||
const SizedBox(height: 80), // Espace pour le FAB
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is OrganizationsLoadingMore) {
|
||||
// Show current organizations with loading indicator at bottom
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLoadingMorePlaceholder(state.currentOrganizations),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(SpacingTokens.md),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: UnionFlowColors.unionGreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is OrganizationsError) {
|
||||
return _buildErrorState(state);
|
||||
}
|
||||
|
||||
return _buildLoadingState();
|
||||
}
|
||||
|
||||
/// Placeholder pour affichage pendant le chargement de plus d'organisations
|
||||
Widget _buildLoadingMorePlaceholder(List<OrganizationModel> currentOrganizations) {
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: currentOrganizations.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: SpacingTokens.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final org = currentOrganizations[index];
|
||||
return OrganizationCard(
|
||||
organization: org,
|
||||
onTap: () => _showOrganizationDetails(org),
|
||||
showActions: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Bouton d'action harmonisé avec Design System V2
|
||||
Widget? _buildActionButton(OrganizationsState state) {
|
||||
// Afficher le FAB seulement si les données sont chargées
|
||||
if (state is! OrganizationsLoaded && state is! OrganizationsLoadingMore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: _showCreateOrganizationDialog,
|
||||
backgroundColor: UnionFlowColors.unionGreen,
|
||||
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é avec Design System V2
|
||||
Widget _buildHeader(OrganizationsLoaded state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
decoration: BoxDecoration(
|
||||
gradient: UnionFlowColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.lg),
|
||||
boxShadow: UnionFlowColors.greenGlowShadow,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.business,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Gestion des Organisations',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${state.filteredOrganizations.length} organisation(s)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.read<OrganizationsBloc>().add(const RefreshOrganizations());
|
||||
},
|
||||
icon: const Icon(Icons.refresh, color: Colors.white),
|
||||
tooltip: 'Rafraîchir',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Section statistiques avec données réelles et Design System V2
|
||||
Widget _buildStatsSection(OrganizationsLoaded state) {
|
||||
final totalOrgs = state.organizations.length;
|
||||
final activeOrgs = state.organizations.where((o) => o.statut == StatutOrganization.active).length;
|
||||
final totalMembers = state.organizations.fold<int>(0, (sum, o) => sum + o.nombreMembres);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
decoration: BoxDecoration(
|
||||
color: UnionFlowColors.surface,
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.lg),
|
||||
boxShadow: UnionFlowColors.softShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.analytics_outlined,
|
||||
color: UnionFlowColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.xs),
|
||||
const Text(
|
||||
'Statistiques',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: UnionFlowColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Total',
|
||||
totalOrgs.toString(),
|
||||
Icons.business_outlined,
|
||||
UnionFlowColors.unionGreen,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Actives',
|
||||
activeOrgs.toString(),
|
||||
Icons.check_circle_outline,
|
||||
UnionFlowColors.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Membres',
|
||||
totalMembers.toString(),
|
||||
Icons.people_outline,
|
||||
UnionFlowColors.info,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte de statistique avec Design System V2
|
||||
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UnionFlowColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: UnionFlowColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Barre de recherche avec Design System V2
|
||||
Widget _buildSearchBar(OrganizationsLoaded state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
decoration: BoxDecoration(
|
||||
color: UnionFlowColors.surface,
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.lg),
|
||||
boxShadow: UnionFlowColors.softShadow,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UnionFlowColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||
border: Border.all(
|
||||
color: UnionFlowColors.border,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
context.read<OrganizationsBloc>().add(SearchOrganizations(value));
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher par nom, type, localisation...',
|
||||
hintStyle: const TextStyle(
|
||||
color: UnionFlowColors.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search, color: UnionFlowColors.unionGreen),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
context.read<OrganizationsBloc>().add(const SearchOrganizations(''));
|
||||
},
|
||||
icon: const Icon(Icons.clear, color: UnionFlowColors.textSecondary),
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: SpacingTokens.md,
|
||||
vertical: SpacingTokens.sm,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Onglets de catégories générés dynamiquement selon les types disponibles
|
||||
Widget _buildCategoryTabs(List<TypeOrganization?> availableTypes) {
|
||||
if (_tabController == null || availableTypes.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocBuilder<OrganizationsBloc, OrganizationsState>(
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UnionFlowColors.surface,
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.lg),
|
||||
boxShadow: UnionFlowColors.softShadow,
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController!,
|
||||
isScrollable: availableTypes.length > 4, // Scrollable si plus de 4 types
|
||||
labelColor: UnionFlowColors.unionGreen,
|
||||
unselectedLabelColor: UnionFlowColors.textSecondary,
|
||||
indicatorColor: UnionFlowColors.unionGreen,
|
||||
indicatorWeight: 3,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
onTap: (index) {
|
||||
// Filtrer par type selon l'onglet sélectionné
|
||||
final selectedType = availableTypes[index];
|
||||
|
||||
if (selectedType != null) {
|
||||
context.read<OrganizationsBloc>().add(FilterOrganizationsByType(selectedType));
|
||||
} else {
|
||||
// null = "Toutes" → effacer les filtres
|
||||
context.read<OrganizationsBloc>().add(const ClearOrganizationsFilters());
|
||||
}
|
||||
},
|
||||
tabs: availableTypes.map((type) {
|
||||
// null = "Toutes", sinon utiliser le displayName du type
|
||||
final label = type == null ? 'Toutes' : type.displayName;
|
||||
final icon = type?.icon; // Emoji du type
|
||||
|
||||
return Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Text(icon, style: const TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: SpacingTokens.xs),
|
||||
],
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Liste des organisations avec données réelles et OrganizationCard
|
||||
Widget _buildOrganizationsList(OrganizationsLoaded state) {
|
||||
final organizations = state.filteredOrganizations;
|
||||
|
||||
if (organizations.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: organizations.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: SpacingTokens.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final org = organizations[index];
|
||||
return AnimatedFadeIn(
|
||||
duration: Duration(milliseconds: 300 + (index * 50)),
|
||||
child: OrganizationCard(
|
||||
organization: org,
|
||||
onTap: () => _showOrganizationDetails(org),
|
||||
onEdit: () => _showEditOrganizationDialog(org),
|
||||
onDelete: () => _confirmDeleteOrganization(org),
|
||||
showActions: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// État vide avec Design System V2
|
||||
Widget _buildEmptyState() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.xl),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.xl),
|
||||
decoration: BoxDecoration(
|
||||
color: UnionFlowColors.unionGreenPale,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.business_outlined,
|
||||
size: 64,
|
||||
color: UnionFlowColors.unionGreen,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
const Text(
|
||||
'Aucune organisation trouvée',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: UnionFlowColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
const Text(
|
||||
'Essayez de modifier vos critères de recherche\nou créez une nouvelle organisation',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: UnionFlowColors.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
context.read<OrganizationsBloc>().add(const ClearOrganizationsFilters());
|
||||
_searchController.clear();
|
||||
},
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Réinitialiser les filtres'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UnionFlowColors.unionGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: SpacingTokens.lg,
|
||||
vertical: SpacingTokens.sm,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// État de chargement avec Design System V2
|
||||
Widget _buildLoadingState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: UnionFlowColors.unionGreen,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
const Text(
|
||||
'Chargement des organisations...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: UnionFlowColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// État d'erreur avec Design System V2
|
||||
Widget _buildErrorState(OrganizationsError state) {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(SpacingTokens.xl),
|
||||
padding: const EdgeInsets.all(SpacingTokens.xl),
|
||||
decoration: BoxDecoration(
|
||||
color: UnionFlowColors.errorPale,
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.lg),
|
||||
border: Border.all(
|
||||
color: UnionFlowColors.errorLight,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: UnionFlowColors.error,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Text(
|
||||
state.message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: UnionFlowColors.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (state.details != null) ...[
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
state.details!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: UnionFlowColors.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
context.read<OrganizationsBloc>().add(const LoadOrganizations(refresh: true));
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UnionFlowColors.unionGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: SpacingTokens.lg,
|
||||
vertical: SpacingTokens.sm,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthodes d'actions
|
||||
void _showOrganizationDetails(OrganizationModel org) {
|
||||
final orgId = org.id;
|
||||
if (orgId == null || orgId.isEmpty) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: context.read<OrganizationsBloc>(),
|
||||
child: OrganizationDetailPage(organizationId: orgId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateOrganizationDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const CreateOrganizationDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditOrganizationDialog(OrganizationModel org) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => EditOrganizationDialog(organization: org),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDeleteOrganization(OrganizationModel org) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Supprimer l\'organisation'),
|
||||
content: Text('Voulez-vous vraiment supprimer "${org.nom}" ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (org.id != null) {
|
||||
context.read<OrganizationsBloc>().add(DeleteOrganization(org.id!));
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UnionFlowColors.error,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// 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 'package:get_it/get_it.dart';
|
||||
import '../../../authentication/data/models/user_role.dart';
|
||||
import '../../../authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../bloc/organizations_bloc.dart';
|
||||
import '../../bloc/organizations_event.dart';
|
||||
import 'organizations_page.dart';
|
||||
|
||||
final _getIt = GetIt.instance;
|
||||
|
||||
/// 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) {
|
||||
final bloc = _getIt<OrganizationsBloc>();
|
||||
// Admin d'organisation : ne charger que son/ses organisation(s)
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
final useMesOnly = authState is AuthAuthenticated &&
|
||||
authState.effectiveRole == UserRole.orgAdmin;
|
||||
bloc.add(LoadOrganizations(useMesOnly: useMesOnly));
|
||||
return bloc;
|
||||
},
|
||||
child: const OrganizationsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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://',
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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://',
|
||||
),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(', ');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user