Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 16:30:08 +00:00
commit d094d6db9c
1790 changed files with 507435 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
/// BLoC pour la gestion des adhésions (demandes d'adhésion)
library adhesions_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:injectable/injectable.dart';
import '../../../core/utils/logger.dart';
import '../data/models/adhesion_model.dart';
import '../data/repositories/adhesion_repository.dart';
part 'adhesions_event.dart';
part 'adhesions_state.dart';
@injectable
class AdhesionsBloc extends Bloc<AdhesionsEvent, AdhesionsState> {
final AdhesionRepository _repository;
AdhesionsBloc(this._repository) : super(const AdhesionsState()) {
on<LoadAdhesions>(_onLoadAdhesions);
on<LoadAdhesionsByMembre>(_onLoadAdhesionsByMembre);
on<LoadAdhesionsEnAttente>(_onLoadAdhesionsEnAttente);
on<LoadAdhesionsByStatut>(_onLoadAdhesionsByStatut);
on<LoadAdhesionById>(_onLoadAdhesionById);
on<CreateAdhesion>(_onCreateAdhesion);
on<ApprouverAdhesion>(_onApprouverAdhesion);
on<RejeterAdhesion>(_onRejeterAdhesion);
on<EnregistrerPaiementAdhesion>(_onEnregistrerPaiementAdhesion);
on<LoadAdhesionsStats>(_onLoadAdhesionsStats);
}
Future<void> _onLoadAdhesions(LoadAdhesions event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getAll(page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsByMembre(LoadAdhesionsByMembre event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getByMembre(event.membreId, page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsEnAttente(LoadAdhesionsEnAttente event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getEnAttente(page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsByStatut(LoadAdhesionsByStatut event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getByStatut(event.statut, page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionById(LoadAdhesionById event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final adhesion = await _repository.getById(event.id);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: adhesion));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onCreateAdhesion(CreateAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Création...'));
try {
await _repository.create(event.adhesion);
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onApprouverAdhesion(ApprouverAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.approuver(event.id, approuvePar: event.approuvePar);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onRejeterAdhesion(RejeterAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.rejeter(event.id, event.motifRejet);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onEnregistrerPaiementAdhesion(EnregistrerPaiementAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.enregistrerPaiement(
event.id,
montantPaye: event.montantPaye,
methodePaiement: event.methodePaiement,
referencePaiement: event.referencePaiement,
);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsStats(LoadAdhesionsStats event, Emitter<AdhesionsState> emit) async {
try {
final stats = await _repository.getStats();
emit(state.copyWith(stats: stats));
} catch (e, st) {
AppLogger.error('AdhesionsBloc: chargement stats échoué', error: e, stackTrace: st);
emit(state.copyWith(
status: AdhesionsStatus.error,
message: e.toString(),
error: e,
));
}
}
}

View File

@@ -0,0 +1,90 @@
part of 'adhesions_bloc.dart';
abstract class AdhesionsEvent extends Equatable {
const AdhesionsEvent();
@override
List<Object?> get props => [];
}
class LoadAdhesions extends AdhesionsEvent {
final int page;
final int size;
const LoadAdhesions({this.page = 0, this.size = 20});
@override
List<Object?> get props => [page, size];
}
class LoadAdhesionsByMembre extends AdhesionsEvent {
final String membreId;
final int page;
final int size;
const LoadAdhesionsByMembre(this.membreId, {this.page = 0, this.size = 20});
@override
List<Object?> get props => [membreId, page, size];
}
class LoadAdhesionsEnAttente extends AdhesionsEvent {
final int page;
final int size;
const LoadAdhesionsEnAttente({this.page = 0, this.size = 20});
@override
List<Object?> get props => [page, size];
}
class LoadAdhesionsByStatut extends AdhesionsEvent {
final String statut;
final int page;
final int size;
const LoadAdhesionsByStatut(this.statut, {this.page = 0, this.size = 20});
@override
List<Object?> get props => [statut, page, size];
}
class LoadAdhesionById extends AdhesionsEvent {
final String id;
const LoadAdhesionById(this.id);
@override
List<Object?> get props => [id];
}
class CreateAdhesion extends AdhesionsEvent {
final AdhesionModel adhesion;
const CreateAdhesion(this.adhesion);
@override
List<Object?> get props => [adhesion];
}
class ApprouverAdhesion extends AdhesionsEvent {
final String id;
final String? approuvePar;
const ApprouverAdhesion(this.id, {this.approuvePar});
@override
List<Object?> get props => [id, approuvePar];
}
class RejeterAdhesion extends AdhesionsEvent {
final String id;
final String motifRejet;
const RejeterAdhesion(this.id, this.motifRejet);
@override
List<Object?> get props => [id, motifRejet];
}
class EnregistrerPaiementAdhesion extends AdhesionsEvent {
final String id;
final double montantPaye;
final String? methodePaiement;
final String? referencePaiement;
const EnregistrerPaiementAdhesion(
this.id, {
required this.montantPaye,
this.methodePaiement,
this.referencePaiement,
});
@override
List<Object?> get props => [id, montantPaye, methodePaiement, referencePaiement];
}
class LoadAdhesionsStats extends AdhesionsEvent {
const LoadAdhesionsStats();
}

View File

@@ -0,0 +1,42 @@
part of 'adhesions_bloc.dart';
enum AdhesionsStatus { initial, loading, loaded, error }
class AdhesionsState extends Equatable {
final AdhesionsStatus status;
final List<AdhesionModel> adhesions;
final AdhesionModel? adhesionDetail;
final Map<String, dynamic>? stats;
final String? message;
final Object? error;
const AdhesionsState({
this.status = AdhesionsStatus.initial,
this.adhesions = const [],
this.adhesionDetail,
this.stats,
this.message,
this.error,
});
AdhesionsState copyWith({
AdhesionsStatus? status,
List<AdhesionModel>? adhesions,
AdhesionModel? adhesionDetail,
Map<String, dynamic>? stats,
String? message,
Object? error,
}) {
return AdhesionsState(
status: status ?? this.status,
adhesions: adhesions ?? this.adhesions,
adhesionDetail: adhesionDetail ?? this.adhesionDetail,
stats: stats ?? this.stats,
message: message ?? this.message,
error: error ?? this.error,
);
}
@override
List<Object?> get props => [status, adhesions, adhesionDetail, stats, message, error];
}

View File

@@ -0,0 +1,139 @@
/// Modèle de données pour les adhésions (demandes d'adhésion à une organisation)
/// Correspond à l'API AdhesionResource / AdhesionDTO
library adhesion_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'adhesion_model.g.dart';
/// Statut d'une demande d'adhésion
enum StatutAdhesion {
@JsonValue('EN_ATTENTE')
enAttente,
@JsonValue('APPROUVEE')
approuvee,
@JsonValue('REJETEE')
rejetee,
@JsonValue('ANNULEE')
annulee,
@JsonValue('EN_PAIEMENT')
enPaiement,
@JsonValue('PAYEE')
payee,
}
/// Modèle d'une adhésion
@JsonSerializable(explicitToJson: true)
class AdhesionModel extends Equatable {
final String? id;
final String? numeroReference;
final String? membreId;
final String? numeroMembre;
final String? nomMembre;
final String? emailMembre;
final String? organisationId;
final String? nomOrganisation;
final DateTime? dateDemande;
final double? fraisAdhesion;
final double? montantPaye;
final String? codeDevise;
final String? statut;
final DateTime? dateApprobation;
final DateTime? datePaiement;
final String? methodePaiement;
final String? referencePaiement;
final String? motifRejet;
final String? observations;
final String? approuvePar;
final DateTime? dateCreation;
final DateTime? dateModification;
const AdhesionModel({
this.id,
this.numeroReference,
this.membreId,
this.numeroMembre,
this.nomMembre,
this.emailMembre,
this.organisationId,
this.nomOrganisation,
this.dateDemande,
this.fraisAdhesion,
this.montantPaye,
this.codeDevise,
this.statut,
this.dateApprobation,
this.datePaiement,
this.methodePaiement,
this.referencePaiement,
this.motifRejet,
this.observations,
this.approuvePar,
this.dateCreation,
this.dateModification,
});
factory AdhesionModel.fromJson(Map<String, dynamic> json) =>
_$AdhesionModelFromJson(json);
Map<String, dynamic> toJson() => _$AdhesionModelToJson(this);
/// Montant restant à payer
double get montantRestant {
if (fraisAdhesion == null) return 0;
final paye = montantPaye ?? 0;
final restant = fraisAdhesion! - paye;
return restant > 0 ? restant : 0;
}
/// Pourcentage payé
int get pourcentagePaiement {
if (fraisAdhesion == null || fraisAdhesion! == 0) return 0;
if (montantPaye == null) return 0;
return ((montantPaye! / fraisAdhesion!) * 100).round();
}
bool get estPayeeIntegralement =>
fraisAdhesion != null &&
montantPaye != null &&
montantPaye! >= fraisAdhesion!;
bool get estEnAttentePaiement =>
statut == 'APPROUVEE' && !estPayeeIntegralement;
String get statutLibelle {
switch (statut) {
case 'EN_ATTENTE':
return 'En attente';
case 'APPROUVEE':
return 'Approuvée';
case 'REJETEE':
return 'Rejetée';
case 'ANNULEE':
return 'Annulée';
case 'EN_PAIEMENT':
return 'En paiement';
case 'PAYEE':
return 'Payée';
default:
return statut ?? 'Non défini';
}
}
String get nomMembreComplet =>
[nomMembre, numeroMembre].where((e) => e != null && e.isNotEmpty).join(' ').trim().isEmpty
? (emailMembre ?? 'Membre')
: [nomMembre, numeroMembre].where((e) => e != null && e.isNotEmpty).join(' ').trim();
@override
List<Object?> get props => [
id,
numeroReference,
membreId,
organisationId,
statut,
dateDemande,
fraisAdhesion,
montantPaye,
];
}

View File

@@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'adhesion_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AdhesionModel _$AdhesionModelFromJson(Map<String, dynamic> json) =>
AdhesionModel(
id: json['id'] as String?,
numeroReference: json['numeroReference'] as String?,
membreId: json['membreId'] as String?,
numeroMembre: json['numeroMembre'] as String?,
nomMembre: json['nomMembre'] as String?,
emailMembre: json['emailMembre'] as String?,
organisationId: json['organisationId'] as String?,
nomOrganisation: json['nomOrganisation'] as String?,
dateDemande: json['dateDemande'] == null
? null
: DateTime.parse(json['dateDemande'] as String),
fraisAdhesion: (json['fraisAdhesion'] as num?)?.toDouble(),
montantPaye: (json['montantPaye'] as num?)?.toDouble(),
codeDevise: json['codeDevise'] as String?,
statut: json['statut'] as String?,
dateApprobation: json['dateApprobation'] == null
? null
: DateTime.parse(json['dateApprobation'] as String),
datePaiement: json['datePaiement'] == null
? null
: DateTime.parse(json['datePaiement'] as String),
methodePaiement: json['methodePaiement'] as String?,
referencePaiement: json['referencePaiement'] as String?,
motifRejet: json['motifRejet'] as String?,
observations: json['observations'] as String?,
approuvePar: json['approuvePar'] as String?,
dateCreation: json['dateCreation'] == null
? null
: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
);
Map<String, dynamic> _$AdhesionModelToJson(AdhesionModel instance) =>
<String, dynamic>{
'id': instance.id,
'numeroReference': instance.numeroReference,
'membreId': instance.membreId,
'numeroMembre': instance.numeroMembre,
'nomMembre': instance.nomMembre,
'emailMembre': instance.emailMembre,
'organisationId': instance.organisationId,
'nomOrganisation': instance.nomOrganisation,
'dateDemande': instance.dateDemande?.toIso8601String(),
'fraisAdhesion': instance.fraisAdhesion,
'montantPaye': instance.montantPaye,
'codeDevise': instance.codeDevise,
'statut': instance.statut,
'dateApprobation': instance.dateApprobation?.toIso8601String(),
'datePaiement': instance.datePaiement?.toIso8601String(),
'methodePaiement': instance.methodePaiement,
'referencePaiement': instance.referencePaiement,
'motifRejet': instance.motifRejet,
'observations': instance.observations,
'approuvePar': instance.approuvePar,
'dateCreation': instance.dateCreation?.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
};

View File

@@ -0,0 +1,182 @@
/// Repository pour la gestion des adhésions (demandes d'adhésion)
/// Interface avec l'API backend AdhesionResource
library adhesion_repository;
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import '../models/adhesion_model.dart';
abstract class AdhesionRepository {
Future<List<AdhesionModel>> getAll({int page = 0, int size = 20});
Future<AdhesionModel?> getById(String id);
Future<AdhesionModel> create(AdhesionModel adhesion);
Future<AdhesionModel> approuver(String id, {String? approuvePar});
Future<AdhesionModel> rejeter(String id, String motifRejet);
Future<AdhesionModel> enregistrerPaiement(
String id, {
required double montantPaye,
String? methodePaiement,
String? referencePaiement,
});
Future<List<AdhesionModel>> getByMembre(String membreId, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getByOrganisation(String organisationId, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getByStatut(String statut, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getEnAttente({int page = 0, int size = 20});
Future<Map<String, dynamic>?> getStats();
}
@LazySingleton(as: AdhesionRepository)
class AdhesionRepositoryImpl implements AdhesionRepository {
final ApiClient _apiClient;
static const String _base = '/api/adhesions';
AdhesionRepositoryImpl(this._apiClient);
/// Parse une réponse API : liste directe ou objet paginé avec clé "content".
List<AdhesionModel> _parseListResponse(dynamic data) {
if (data is List) {
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
if (data is Map && data.containsKey('content')) {
final content = data['content'] as List<dynamic>? ?? [];
return content
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<List<AdhesionModel>> getAll({int page = 0, int size = 20}) async {
final response = await _apiClient.get(
_base,
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<AdhesionModel?> getById(String id) async {
final response = await _apiClient.get('$_base/$id');
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
if (response.statusCode == 404) return null;
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<AdhesionModel> create(AdhesionModel adhesion) async {
final body = adhesion.toJson();
// Backend attend membreId, organisationId, fraisAdhesion, codeDevise (optionnel)
final response = await _apiClient.post(_base, data: body);
if (response.statusCode == 201 || response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur création: ${response.statusCode}');
}
@override
Future<AdhesionModel> approuver(String id, {String? approuvePar}) async {
final response = await _apiClient.post(
'$_base/$id/approuver',
queryParameters: approuvePar != null ? {'approuvePar': approuvePar} : null,
);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur approbation: ${response.statusCode}');
}
@override
Future<AdhesionModel> rejeter(String id, String motifRejet) async {
final response = await _apiClient.post(
'$_base/$id/rejeter',
queryParameters: {'motifRejet': motifRejet},
);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur rejet: ${response.statusCode}');
}
@override
Future<AdhesionModel> enregistrerPaiement(
String id, {
required double montantPaye,
String? methodePaiement,
String? referencePaiement,
}) async {
final q = <String, dynamic>{'montantPaye': montantPaye};
if (methodePaiement != null) q['methodePaiement'] = methodePaiement;
if (referencePaiement != null) q['referencePaiement'] = referencePaiement;
final response = await _apiClient.post('$_base/$id/paiement', queryParameters: q);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur paiement: ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByMembre(String membreId, {int page = 0, int size = 20}) async {
final response = await _apiClient.get(
'$_base/membre/$membreId',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByOrganisation(String organisationId, {int page = 0, int size = 20}) async {
final response = await _apiClient.get(
'$_base/organisation/$organisationId',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByStatut(String statut, {int page = 0, int size = 20}) async {
final response = await _apiClient.get(
'$_base/statut/$statut',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getEnAttente({int page = 0, int size = 20}) async {
final response = await _apiClient.get(
'$_base/en-attente',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<Map<String, dynamic>?> getStats() async {
final response = await _apiClient.get('$_base/stats');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
return null;
}
}

View File

@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/mini_avatar.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import '../widgets/paiement_adhesion_dialog.dart';
import '../widgets/rejet_adhesion_dialog.dart';
import '../../../authentication/presentation/bloc/auth_bloc.dart';
class AdhesionDetailPage extends StatefulWidget {
final String adhesionId;
const AdhesionDetailPage({super.key, required this.adhesionId});
@override
State<AdhesionDetailPage> createState() => _AdhesionDetailPageState();
}
class _AdhesionDetailPageState extends State<AdhesionDetailPage> {
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
@override
void initState() {
super.initState();
context.read<AdhesionsBloc>().add(LoadAdhesionById(widget.adhesionId));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: const UFAppBar(
title: 'DÉTAIL ADHÉSION',
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimaryLight,
),
body: BlocConsumer<AdhesionsBloc, AdhesionsState>(
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
if (state.status == AdhesionsStatus.error && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!), backgroundColor: Colors.red),
);
}
},
buildWhen: (prev, curr) =>
prev.adhesionDetail != curr.adhesionDetail || prev.status != curr.status,
builder: (context, state) {
if (state.status == AdhesionsStatus.loading && state.adhesionDetail == null) {
return const Center(child: CircularProgressIndicator());
}
final a = state.adhesionDetail;
if (a == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
'Adhésion introuvable',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoCard(
title: 'Référence',
value: a.numeroReference ?? a.id ?? '',
),
_InfoCard(
title: 'Statut',
value: a.statutLibelle,
trail: _buildStatutBadge(a.statut),
),
_InfoCard(
title: 'Organisation',
value: a.nomOrganisation ?? a.organisationId ?? '',
),
_InfoCard(
title: 'Membre',
value: a.nomMembreComplet,
),
if (a.emailMembre != null && a.emailMembre!.isNotEmpty)
_InfoCard(title: 'Email', value: a.emailMembre!),
if (a.dateDemande != null)
_InfoCard(
title: 'Date demande',
value: DateFormat('dd/MM/yyyy').format(a.dateDemande!),
),
_InfoCard(
title: 'Frais d\'adhésion',
value: a.fraisAdhesion != null
? _currencyFormat.format(a.fraisAdhesion)
: '',
),
if (a.montantPaye != null && a.montantPaye! > 0)
_InfoCard(
title: 'Montant payé',
value: _currencyFormat.format(a.montantPaye!),
),
if (a.montantRestant > 0)
_InfoCard(
title: 'Montant restant',
value: _currencyFormat.format(a.montantRestant),
),
if (a.motifRejet != null && a.motifRejet!.isNotEmpty)
_InfoCard(title: 'Motif rejet', value: a.motifRejet!),
_ActionsSection(adhesion: a, currencyFormat: _currencyFormat, isGestionnaire: _isGestionnaire()),
],
),
);
},
),
);
}
bool _isGestionnaire() {
final state = context.read<AuthBloc>().state;
if (state is AuthAuthenticated) {
return state.effectiveRole.level >= 50;
}
return false;
}
}
class _InfoCard extends StatelessWidget {
final String title;
final String value;
final Widget? trail;
const _InfoCard({required this.title, required this.value, this.trail});
@override
Widget build(BuildContext context) {
return CoreCard(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
fontSize: 9,
color: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 2),
Text(
value,
style: AppTypography.bodyTextSmall.copyWith(fontSize: 12),
),
],
),
),
if (trail != null) trail!,
],
),
);
}
}
Widget _buildStatutBadge(String? statut) {
Color color;
switch (statut) {
case 'APPROUVEE':
case 'PAYEE':
color = AppColors.success;
break;
case 'REJETEE':
case 'ANNULEE':
color = AppColors.error;
break;
case 'EN_ATTENTE':
color = AppColors.brandGreenLight;
break;
case 'EN_PAIEMENT':
color = Colors.blue;
break;
default:
color = AppColors.textSecondaryLight;
}
return InfoBadge(text: statut ?? 'INCONNU', backgroundColor: color);
}
class _ActionsSection extends StatelessWidget {
final AdhesionModel adhesion;
final NumberFormat currencyFormat;
final bool isGestionnaire;
const _ActionsSection({
required this.adhesion,
required this.currencyFormat,
required this.isGestionnaire,
});
@override
Widget build(BuildContext context) {
if (!isGestionnaire) return const SizedBox.shrink(); // Normal members cannot approve/pay an adhesion on someone else's behalf (or their own) currently in the UI design.
final bloc = context.read<AdhesionsBloc>();
if (adhesion.statut == 'EN_ATTENTE') {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'ACTIONS ADMINISTRATIVES',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
if (adhesion.id == null) return;
bloc.add(ApprouverAdhesion(adhesion.id!));
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('APPROUVER', style: AppTypography.actionText.copyWith(fontSize: 11, color: Colors.white)),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: () {
if (adhesion.id == null) return;
showDialog<void>(
context: context,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: RejetAdhesionDialog(
adhesionId: adhesion.id!,
onRejected: () => Navigator.of(ctx).pop(),
),
),
);
},
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('REJETER', style: AppTypography.actionText.copyWith(fontSize: 11)),
),
),
],
),
],
);
}
if (adhesion.estEnAttentePaiement && adhesion.id != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'PAIEMENT',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {
showDialog<void>(
context: context,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: PaiementAdhesionDialog(
adhesionId: adhesion.id!,
montantRestant: adhesion.montantRestant,
onPaid: () => Navigator.of(ctx).pop(),
),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('ENREGISTRER UN PAIEMENT', style: AppTypography.actionText.copyWith(fontSize: 11, color: Colors.white)),
),
],
);
}
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/mini_avatar.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import 'adhesion_detail_page.dart';
import '../widgets/create_adhesion_dialog.dart';
import '../../../authentication/presentation/bloc/auth_bloc.dart';
class AdhesionsPage extends StatefulWidget {
const AdhesionsPage({super.key});
@override
State<AdhesionsPage> createState() => _AdhesionsPageState();
}
class _AdhesionsPageState extends State<AdhesionsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_loadTab(0);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _loadTab(int index) {
bool isGestionnaire = false;
String? membreId;
final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) {
isGestionnaire = authState.effectiveRole.level >= 50;
membreId = authState.user.id;
}
if (isGestionnaire) {
switch (index) {
case 0:
context.read<AdhesionsBloc>().add(const LoadAdhesions());
break;
case 1:
context.read<AdhesionsBloc>().add(const LoadAdhesionsEnAttente());
break;
case 2:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('APPROUVEE'));
break;
case 3:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('PAYEE'));
break;
}
} else {
// Normal member: always fetch their own records to ensure security
if (membreId != null) {
context.read<AdhesionsBloc>().add(LoadAdhesionsByMembre(membreId));
}
}
}
@override
Widget build(BuildContext context) {
return BlocListener<AdhesionsBloc, AdhesionsState>(
listener: (context, state) {
if (state.status == AdhesionsStatus.error && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message!),
backgroundColor: Colors.red,
action: SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: () => _loadTab(_tabController.index),
),
),
);
}
},
child: Scaffold(
backgroundColor: AppColors.background,
appBar: UFAppBar(
title: 'ADHÉSIONS',
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimaryLight,
actions: [
IconButton(
icon: const Icon(Icons.add, size: 20),
onPressed: () => _showCreateDialog(),
tooltip: 'Nouvelle demande',
),
],
bottom: TabBar(
controller: _tabController,
onTap: _loadTab,
isScrollable: true,
labelColor: AppColors.primaryGreen,
unselectedLabelColor: AppColors.textSecondaryLight,
indicatorColor: AppColors.primaryGreen,
indicatorSize: TabBarIndicatorSize.label,
labelStyle: AppTypography.actionText.copyWith(fontSize: 10, fontWeight: FontWeight.bold),
tabs: const [
Tab(child: Text('TOUTES')),
Tab(child: Text('ATTENTE')),
Tab(child: Text('APPROUVÉES')),
Tab(child: Text('PAYÉES')),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildList(null),
_buildList('EN_ATTENTE'),
_buildList('APPROUVEE'), // tab 2 charge déjà par statut
_buildList('PAYEE'), // tab 3 charge déjà par statut
],
),
),
);
}
Widget _buildList(String? statutFilter) {
return BlocBuilder<AdhesionsBloc, AdhesionsState>(
buildWhen: (prev, curr) =>
prev.status != curr.status || prev.adhesions != curr.adhesions,
builder: (context, state) {
if (state.status == AdhesionsStatus.loading && state.adhesions.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
var list = state.adhesions;
if (statutFilter != null) {
list = list.where((a) => a.statut == statutFilter).toList();
}
if (list.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.assignment_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Aucune demande d\'adhésion',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => _showCreateDialog(),
icon: const Icon(Icons.add),
label: const Text('Créer une demande'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => _loadTab(_tabController.index),
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: list.length,
itemBuilder: (context, index) {
final a = list[index];
return _AdhesionCard(
adhesion: a,
currencyFormat: _currencyFormat,
onTap: () => _openDetail(a),
);
},
),
);
},
);
}
void _openDetail(AdhesionModel a) {
if (a.id == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: context.read<AdhesionsBloc>(),
child: AdhesionDetailPage(adhesionId: a.id!),
),
),
).then((_) => _loadTab(_tabController.index));
}
void _showCreateDialog() {
showDialog<void>(
context: context,
builder: (context) => CreateAdhesionDialog(
onCreated: () {
Navigator.of(context).pop();
_loadTab(_tabController.index);
},
),
);
}
}
class _AdhesionCard extends StatelessWidget {
final AdhesionModel adhesion;
final NumberFormat currencyFormat;
final VoidCallback onTap;
const _AdhesionCard({
required this.adhesion,
required this.currencyFormat,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return CoreCard(
margin: const EdgeInsets.only(bottom: 10),
onTap: onTap,
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MiniAvatar(size: 24, fallbackText: '🏢'),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
adhesion.nomOrganisation ?? adhesion.organisationId ?? 'Organisation',
style: AppTypography.actionText.copyWith(fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
adhesion.numeroReference ?? adhesion.id?.substring(0, 8) ?? '',
style: AppTypography.subtitleSmall.copyWith(fontSize: 9),
),
],
),
),
_buildStatutBadge(adhesion.statut),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('FRAIS D\'ADHÉSION', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
Text(
adhesion.fraisAdhesion != null ? currencyFormat.format(adhesion.fraisAdhesion) : '',
style: AppTypography.headerSmall.copyWith(fontSize: 13, color: AppColors.primaryGreen),
),
],
),
if (adhesion.dateDemande != null)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('DATE', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
Text(
DateFormat('dd/MM/yyyy').format(adhesion.dateDemande!),
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10),
),
],
),
],
),
if (adhesion.nomMembreComplet.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'MEMBRE : ${adhesion.nomMembreComplet.toUpperCase()}',
style: AppTypography.subtitleSmall.copyWith(fontSize: 8, color: AppColors.textSecondaryLight),
),
],
],
),
);
}
Widget _buildStatutBadge(String? statut) {
Color color;
switch (statut) {
case 'APPROUVEE':
case 'PAYEE':
color = AppColors.success;
break;
case 'REJETEE':
case 'ANNULEE':
color = AppColors.error;
break;
case 'EN_ATTENTE':
color = AppColors.brandGreenLight;
break;
case 'EN_PAIEMENT':
color = Colors.blue;
break;
default:
color = AppColors.textSecondaryLight;
}
return InfoBadge(text: statut ?? 'INCONNU', backgroundColor: color);
}
}

View File

@@ -0,0 +1,26 @@
/// Wrapper BLoC pour la page des adhésions
library adhesions_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../bloc/adhesions_bloc.dart';
import 'adhesions_page.dart';
final _getIt = GetIt.instance;
class AdhesionsPageWrapper extends StatelessWidget {
const AdhesionsPageWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<AdhesionsBloc>(
create: (context) {
final bloc = _getIt<AdhesionsBloc>();
bloc.add(const LoadAdhesions());
return bloc;
},
child: const AdhesionsPage(),
);
}
}

View File

@@ -0,0 +1,175 @@
/// Dialog de création d'une demande d'adhésion
library create_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/utils/logger.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import '../../../organizations/data/models/organization_model.dart';
import '../../../organizations/domain/repositories/organization_repository.dart';
import '../../../members/data/models/membre_complete_model.dart';
import '../../../profile/domain/repositories/profile_repository.dart';
class CreateAdhesionDialog extends StatefulWidget {
final VoidCallback onCreated;
const CreateAdhesionDialog({super.key, required this.onCreated});
@override
State<CreateAdhesionDialog> createState() => _CreateAdhesionDialogState();
}
class _CreateAdhesionDialogState extends State<CreateAdhesionDialog> {
final _fraisController = TextEditingController();
String? _organisationId;
bool _loading = false;
bool _isInitLoading = true;
List<OrganizationModel> _organisations = [];
MembreCompletModel? _me;
@override
void initState() {
super.initState();
_loadInitialData();
}
Future<void> _loadInitialData() async {
try {
final user = await GetIt.instance<IProfileRepository>().getMe();
final orgRepo = GetIt.instance<IOrganizationRepository>();
final list = await orgRepo.getOrganizations(page: 0, size: 100);
if (mounted) {
setState(() {
_me = user;
_organisations = list;
_isInitLoading = false;
});
}
} catch (e, st) {
AppLogger.error('CreateAdhesionDialog: chargement profil/organisations échoué', error: e, stackTrace: st);
if (mounted) {
setState(() {
_isInitLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger le profil ou les organisations. Réessayez.')),
);
}
}
}
@override
void dispose() {
_fraisController.dispose();
super.dispose();
}
void _submit() {
if (_me == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profil non chargé, veuillez réessayer')),
);
return;
}
if (_organisationId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez sélectionner un membre et une organisation')),
);
return;
}
final frais = double.tryParse(_fraisController.text.replaceAll(',', '.'));
if (frais == null || frais <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Frais d\'adhésion invalides')),
);
return;
}
setState(() => _loading = true);
final adhesion = AdhesionModel(
membreId: _me!.id,
organisationId: _organisationId,
fraisAdhesion: frais,
codeDevise: 'XOF',
dateDemande: DateTime.now(),
);
context.read<AdhesionsBloc>().add(CreateAdhesion(adhesion));
widget.onCreated();
if (mounted) {
setState(() => _loading = false);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Nouvelle demande d\'adhésion'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_isInitLoading)
const CircularProgressIndicator()
else if (_me != null)
TextFormField(
initialValue: '${_me!.prenom} ${_me!.nom}',
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
enabled: false,
)
else
const Text('Impossible de récupérer votre profil', style: TextStyle(color: Colors.red)),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _organisationId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Organisation',
border: OutlineInputBorder(),
),
items: _organisations
.map((o) => DropdownMenuItem<String>(
value: o.id,
child: Text(o.nom, overflow: TextOverflow.ellipsis, maxLines: 1),
))
.toList(),
onChanged: _loading ? null : (v) => setState(() => _organisationId = v),
),
const SizedBox(height: 16),
TextField(
controller: _fraisController,
decoration: const InputDecoration(
labelText: 'Frais d\'adhésion (FCFA)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_loading,
),
],
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Créer'),
),
],
);
}
}

View File

@@ -0,0 +1,154 @@
/// Dialog pour enregistrer un paiement sur une adhésion
library paiement_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../shared/constants/payment_method_assets.dart';
import '../../bloc/adhesions_bloc.dart';
class PaiementAdhesionDialog extends StatefulWidget {
final String adhesionId;
final double montantRestant;
final VoidCallback onPaid;
const PaiementAdhesionDialog({
super.key,
required this.adhesionId,
required this.montantRestant,
required this.onPaid,
});
@override
State<PaiementAdhesionDialog> createState() => _PaiementAdhesionDialogState();
}
class _PaiementAdhesionDialogState extends State<PaiementAdhesionDialog> {
final _montantController = TextEditingController();
final _refController = TextEditingController();
String? _methode;
bool _loading = false;
@override
void initState() {
super.initState();
_montantController.text = widget.montantRestant.toStringAsFixed(0);
}
@override
void dispose() {
_montantController.dispose();
_refController.dispose();
super.dispose();
}
List<DropdownMenuItem<String>> _buildPaymentMethodItems() {
const codes = ['ESPECES', 'VIREMENT', 'WAVE_MONEY', 'ORANGE_MONEY', 'CHEQUE'];
const labels = {'ESPECES': 'Espèces', 'VIREMENT': 'Virement', 'WAVE_MONEY': 'Wave Money', 'ORANGE_MONEY': 'Orange Money', 'CHEQUE': 'Chèque'};
return codes.map((code) {
final label = labels[code] ?? code;
return DropdownMenuItem<String>(
value: code,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PaymentMethodIcon(paymentMethodCode: code, width: 24, height: 24),
const SizedBox(width: 12),
Text(label),
],
),
);
}).toList();
}
void _submit() {
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Montant invalide')),
);
return;
}
if (montant > widget.montantRestant) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Le montant ne peut pas dépasser le restant dû')),
);
return;
}
setState(() => _loading = true);
context.read<AdhesionsBloc>().add(
EnregistrerPaiementAdhesion(
widget.adhesionId,
montantPaye: montant,
methodePaiement: _methode,
referencePaiement: _refController.text.trim().isEmpty
? null
: _refController.text.trim(),
),
);
widget.onPaid();
if (mounted) {
setState(() => _loading = false);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Enregistrer un paiement'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Restant dû : ${widget.montantRestant.toStringAsFixed(0)} FCFA'),
const SizedBox(height: 16),
TextField(
controller: _montantController,
decoration: const InputDecoration(
labelText: 'Montant payé (FCFA)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_loading,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _methode,
decoration: const InputDecoration(
labelText: 'Méthode de paiement',
border: OutlineInputBorder(),
),
items: _buildPaymentMethodItems(),
onChanged: _loading ? null : (v) => setState(() => _methode = v),
),
const SizedBox(height: 12),
TextField(
controller: _refController,
decoration: const InputDecoration(
labelText: 'Référence (optionnel)',
border: OutlineInputBorder(),
),
enabled: !_loading,
),
],
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enregistrer'),
),
],
);
}
}

View File

@@ -0,0 +1,102 @@
/// Dialog pour rejeter une adhésion (saisie du motif)
library rejet_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/adhesions_bloc.dart';
class RejetAdhesionDialog extends StatefulWidget {
final String adhesionId;
final VoidCallback onRejected;
const RejetAdhesionDialog({
super.key,
required this.adhesionId,
required this.onRejected,
});
@override
State<RejetAdhesionDialog> createState() => _RejetAdhesionDialogState();
}
class _RejetAdhesionDialogState extends State<RejetAdhesionDialog> {
final _controller = TextEditingController();
bool _loading = false;
bool _rejectSent = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final motif = _controller.text.trim();
if (motif.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez saisir un motif de rejet')),
);
return;
}
setState(() {
_loading = true;
_rejectSent = true;
});
context.read<AdhesionsBloc>().add(RejeterAdhesion(widget.adhesionId, motif));
}
@override
Widget build(BuildContext context) {
return BlocListener<AdhesionsBloc, AdhesionsState>(
listenWhen: (_, state) => _rejectSent && (state.status == AdhesionsStatus.loaded || state.status == AdhesionsStatus.error),
listener: (context, state) {
if (!_rejectSent || !mounted) return;
if (state.status == AdhesionsStatus.error) {
setState(() {
_loading = false;
_rejectSent = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message ?? 'Erreur lors du rejet')),
);
return;
}
if (state.status == AdhesionsStatus.loaded) {
setState(() => _rejectSent = false);
widget.onRejected();
Navigator.of(context).pop();
}
},
child: AlertDialog(
title: const Text('Rejeter la demande'),
content: TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Motif du rejet',
hintText: 'Saisir le motif...',
border: OutlineInputBorder(),
),
maxLines: 3,
enabled: !_loading,
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Rejeter'),
),
],
),
);
}
}