Versione OK Pour l'onglet événements.

This commit is contained in:
DahoudG
2025-09-15 20:15:34 +00:00
parent 8a619ee1bf
commit 12d514d866
73 changed files with 11508 additions and 674 deletions

View File

@@ -163,7 +163,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
const SizedBox(height: 16),
// Message de succès
Text(
const Text(
'Nous avons envoyé un lien de réinitialisation à :',
style: TextStyle(
fontSize: 16,
@@ -196,15 +196,15 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
color: AppTheme.infoColor.withOpacity(0.2),
),
),
child: Column(
child: const Column(
children: [
const Icon(
Icon(
Icons.info_outline,
color: AppTheme.infoColor,
size: 24,
),
const SizedBox(height: 12),
const Text(
SizedBox(height: 12),
Text(
'Instructions',
style: TextStyle(
fontSize: 16,
@@ -212,7 +212,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
SizedBox(height: 8),
Text(
'1. Vérifiez votre boîte email (et vos spams)\n'
'2. Cliquez sur le lien de réinitialisation\n'
@@ -291,7 +291,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
const SizedBox(height: 8),
// Sous-titre
Text(
const Text(
'Pas de problème ! Nous allons vous aider à le récupérer.',
style: TextStyle(
fontSize: 16,
@@ -328,11 +328,11 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
),
),
const SizedBox(width: 16),
Expanded(
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Comment ça marche ?',
style: TextStyle(
fontSize: 16,
@@ -340,7 +340,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
SizedBox(height: 4),
Text(
'Saisissez votre email et nous vous enverrons un lien sécurisé pour réinitialiser votre mot de passe.',
style: TextStyle(
@@ -388,7 +388,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
const Text(
'Vous vous souvenez de votre mot de passe ? ',
style: TextStyle(
color: AppTheme.textSecondary,

View File

@@ -190,7 +190,7 @@ class _TempLoginPageState extends State<TempLoginPage>
decoration: InputDecoration(
labelText: 'Adresse email',
hintText: 'votre.email@exemple.com',
prefixIcon: Icon(
prefixIcon: const Icon(
Icons.email_outlined,
color: AppTheme.primaryColor,
),
@@ -202,7 +202,7 @@ class _TempLoginPageState extends State<TempLoginPage>
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
borderSide: const BorderSide(
color: AppTheme.primaryColor,
width: 2,
),
@@ -234,7 +234,7 @@ class _TempLoginPageState extends State<TempLoginPage>
decoration: InputDecoration(
labelText: 'Mot de passe',
hintText: 'Saisissez votre mot de passe',
prefixIcon: Icon(
prefixIcon: const Icon(
Icons.lock_outlined,
color: AppTheme.primaryColor,
),
@@ -260,7 +260,7 @@ class _TempLoginPageState extends State<TempLoginPage>
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
borderSide: const BorderSide(
color: AppTheme.primaryColor,
width: 2,
),
@@ -320,7 +320,7 @@ class _TempLoginPageState extends State<TempLoginPage>
: null,
),
const SizedBox(width: 8),
Text(
const Text(
'Se souvenir de moi',
style: TextStyle(
fontSize: 14,
@@ -340,7 +340,7 @@ class _TempLoginPageState extends State<TempLoginPage>
color: AppTheme.infoColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
child: const Text(
'Compte de test',
style: TextStyle(
fontSize: 12,
@@ -376,12 +376,12 @@ class _TempLoginPageState extends State<TempLoginPage>
strokeWidth: 2,
),
)
: Row(
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.login, size: 20),
const SizedBox(width: 8),
const Text(
Icon(Icons.login, size: 20),
SizedBox(width: 8),
Text(
'Se connecter',
style: TextStyle(
fontSize: 16,

View File

@@ -161,7 +161,7 @@ class _LoginScreenState extends State<LoginScreen>
const SizedBox(height: 8),
// Sous-titre
Text(
const Text(
'Connectez-vous à votre compte UnionFlow',
style: TextStyle(
fontSize: 16,
@@ -269,11 +269,11 @@ class _LoginScreenState extends State<LoginScreen>
}
Widget _buildDivider() {
return Row(
return const Row(
children: [
const Expanded(child: Divider()),
Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'ou',
style: TextStyle(
@@ -282,7 +282,7 @@ class _LoginScreenState extends State<LoginScreen>
),
),
),
const Expanded(child: Divider()),
Expanded(child: Divider()),
],
);
}
@@ -348,7 +348,7 @@ class _LoginScreenState extends State<LoginScreen>
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
const Text(
'Pas encore de compte ? ',
style: TextStyle(
color: AppTheme.textSecondary,

View File

@@ -163,7 +163,7 @@ class _RegisterScreenState extends State<RegisterScreen>
const SizedBox(height: 8),
// Sous-titre
Text(
const Text(
'Rejoignez UnionFlow et gérez votre association',
style: TextStyle(
fontSize: 16,
@@ -386,25 +386,25 @@ class _RegisterScreenState extends State<RegisterScreen>
),
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(
text: const TextSpan(
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
children: [
const TextSpan(text: 'J\'accepte les '),
TextSpan(text: 'J\'accepte les '),
TextSpan(
text: 'Conditions d\'utilisation',
style: const TextStyle(
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
const TextSpan(text: ' et la '),
TextSpan(text: ' et la '),
TextSpan(
text: 'Politique de confidentialité',
style: const TextStyle(
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
@@ -459,7 +459,7 @@ class _RegisterScreenState extends State<RegisterScreen>
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
const Text(
'Déjà un compte ? ',
style: TextStyle(
color: AppTheme.textSecondary,

View File

@@ -87,7 +87,7 @@ class LoginFooter extends StatelessWidget {
color: AppTheme.textSecondary.withOpacity(0.1),
),
),
child: Column(
child: const Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -97,7 +97,7 @@ class LoginFooter extends StatelessWidget {
size: 20,
color: AppTheme.successColor,
),
const SizedBox(width: 8),
SizedBox(width: 8),
Text(
'Connexion sécurisée',
style: TextStyle(
@@ -108,7 +108,7 @@ class LoginFooter extends StatelessWidget {
),
],
),
const SizedBox(height: 8),
SizedBox(height: 8),
Text(
'Vos données sont protégées par un cryptage de niveau bancaire',
textAlign: TextAlign.center,
@@ -174,7 +174,7 @@ class LoginFooter extends StatelessWidget {
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
@@ -216,14 +216,14 @@ class LoginFooter extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
title: const Row(
children: [
Icon(
Icons.help_outline,
color: AppTheme.infoColor,
),
const SizedBox(width: 12),
const Text('Aide'),
SizedBox(width: 12),
Text('Aide'),
],
),
content: Column(
@@ -249,7 +249,7 @@ class LoginFooter extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
child: const Text(
'Fermer',
style: TextStyle(
color: AppTheme.primaryColor,
@@ -269,14 +269,14 @@ class LoginFooter extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
title: const Row(
children: [
Icon(
Icons.info_outline,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
const Text('À propos'),
SizedBox(width: 12),
Text('À propos'),
],
),
content: const Text(
@@ -286,7 +286,7 @@ class LoginFooter extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
child: const Text(
'Fermer',
style: TextStyle(
color: AppTheme.primaryColor,
@@ -306,14 +306,14 @@ class LoginFooter extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
title: const Row(
children: [
Icon(
Icons.privacy_tip_outlined,
color: AppTheme.warningColor,
),
const SizedBox(width: 12),
const Text('Confidentialité'),
SizedBox(width: 12),
Text('Confidentialité'),
],
),
content: const Text(
@@ -323,7 +323,7 @@ class LoginFooter extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
child: const Text(
'Compris',
style: TextStyle(
color: AppTheme.primaryColor,
@@ -342,7 +342,7 @@ class LoginFooter extends StatelessWidget {
children: [
Text(
title,
style: TextStyle(
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
@@ -351,7 +351,7 @@ class LoginFooter extends StatelessWidget {
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),

View File

@@ -189,21 +189,21 @@ class _LoginFormState extends State<LoginForm>
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
borderSide: const BorderSide(
color: AppTheme.primaryColor,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
borderSide: const BorderSide(
color: AppTheme.errorColor,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
borderSide: const BorderSide(
color: AppTheme.errorColor,
width: 2,
),
@@ -281,21 +281,21 @@ class _LoginFormState extends State<LoginForm>
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
borderSide: const BorderSide(
color: AppTheme.primaryColor,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
borderSide: const BorderSide(
color: AppTheme.errorColor,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
borderSide: const BorderSide(
color: AppTheme.errorColor,
width: 2,
),
@@ -344,7 +344,7 @@ class _LoginFormState extends State<LoginForm>
: Colors.transparent,
),
child: widget.rememberMe
? Icon(
? const Icon(
Icons.check,
size: 14,
color: Colors.white,
@@ -352,7 +352,7 @@ class _LoginFormState extends State<LoginForm>
: null,
),
const SizedBox(width: 8),
Flexible(
const Flexible(
child: Text(
'Se souvenir de moi',
style: TextStyle(
@@ -374,7 +374,7 @@ class _LoginFormState extends State<LoginForm>
HapticFeedback.selectionClick();
_showForgotPasswordDialog();
},
child: Text(
child: const Text(
'Mot de passe oublié ?',
style: TextStyle(
fontSize: 14,
@@ -413,14 +413,14 @@ class _LoginFormState extends State<LoginForm>
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
title: const Row(
children: [
Icon(
Icons.help_outline,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
const Text('Mot de passe oublié'),
SizedBox(width: 12),
Text('Mot de passe oublié'),
],
),
content: const Text(
@@ -429,7 +429,7 @@ class _LoginFormState extends State<LoginForm>
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
child: const Text(
'Compris',
style: TextStyle(
color: AppTheme.primaryColor,

View File

@@ -1,15 +1,17 @@
import 'package:injectable/injectable.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/services/api_service.dart';
import '../../../../core/services/cache_service.dart';
import '../../../cotisations/domain/repositories/cotisation_repository.dart';
/// Implémentation du repository des cotisations
/// Utilise ApiService pour communiquer avec le backend
/// Utilise ApiService pour communiquer avec le backend et CacheService pour le cache local
@LazySingleton(as: CotisationRepository)
class CotisationRepositoryImpl implements CotisationRepository {
final ApiService _apiService;
final CacheService _cacheService;
CotisationRepositoryImpl(this._apiService);
CotisationRepositoryImpl(this._apiService, this._cacheService);
@override
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20}) async {
@@ -79,6 +81,54 @@ class CotisationRepositoryImpl implements CotisationRepository {
@override
Future<Map<String, dynamic>> getCotisationsStats() async {
return await _apiService.getCotisationsStats();
// Essayer de récupérer depuis le cache d'abord
final cachedStats = await _cacheService.getCotisationsStats();
if (cachedStats != null) {
return cachedStats.toJson();
}
try {
final stats = await _apiService.getCotisationsStats();
// Sauvegarder en cache si possible
// Note: Conversion nécessaire selon la structure des stats du backend
// await _cacheService.saveCotisationsStats(statsModel);
return stats;
} catch (e) {
// En cas d'erreur, retourner le cache si disponible
if (cachedStats != null) {
return cachedStats.toJson();
}
rethrow;
}
}
/// Invalide tous les caches de listes de cotisations
Future<void> _invalidateListCaches() async {
// Nettoyer les caches de listes paginées
final keys = ['cotisations_page_0_size_20', 'cotisations_cache'];
for (final key in keys) {
await _cacheService.clearCotisations(key: key);
}
// Nettoyer le cache des statistiques
await _cacheService.clearCotisationsStats();
}
/// Force la synchronisation avec le serveur
Future<void> forceSync() async {
await _cacheService.clearAllCotisationsCache();
await _cacheService.updateLastSyncTimestamp();
}
/// Vérifie si une synchronisation est nécessaire
bool needsSync() {
return _cacheService.needsSync();
}
/// Retourne des informations sur le cache
Map<String, dynamic> getCacheInfo() {
return _cacheService.getCacheInfo();
}
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/models/payment_model.dart';
import '../../../../core/services/payment_service.dart';
import '../../../../core/services/notification_service.dart';
import '../../domain/repositories/cotisation_repository.dart';
import 'cotisations_event.dart';
import 'cotisations_state.dart';
@@ -10,8 +13,14 @@ import 'cotisations_state.dart';
@injectable
class CotisationsBloc extends Bloc<CotisationsEvent, CotisationsState> {
final CotisationRepository _cotisationRepository;
final PaymentService _paymentService;
final NotificationService _notificationService;
CotisationsBloc(this._cotisationRepository) : super(const CotisationsInitial()) {
CotisationsBloc(
this._cotisationRepository,
this._paymentService,
this._notificationService,
) : super(const CotisationsInitial()) {
// Enregistrement des handlers d'événements
on<LoadCotisations>(_onLoadCotisations);
on<LoadCotisationById>(_onLoadCotisationById);
@@ -28,6 +37,15 @@ class CotisationsBloc extends Bloc<CotisationsEvent, CotisationsState> {
on<ResetCotisationsState>(_onResetCotisationsState);
on<FilterCotisations>(_onFilterCotisations);
on<SortCotisations>(_onSortCotisations);
// Nouveaux handlers pour les paiements et fonctionnalités avancées
on<InitiatePayment>(_onInitiatePayment);
on<CheckPaymentStatus>(_onCheckPaymentStatus);
on<CancelPayment>(_onCancelPayment);
on<ScheduleNotifications>(_onScheduleNotifications);
on<SyncWithServer>(_onSyncWithServer);
on<ApplyAdvancedFilters>(_onApplyAdvancedFilters);
on<ExportCotisations>(_onExportCotisations);
}
/// Handler pour charger la liste des cotisations
@@ -506,4 +524,207 @@ class CotisationsBloc extends Bloc<CotisationsEvent, CotisationsState> {
emit(currentState.copyWith(filteredCotisations: sortedList));
}
}
/// Handler pour initier un paiement
Future<void> _onInitiatePayment(
InitiatePayment event,
Emitter<CotisationsState> emit,
) async {
try {
// Valider les données de paiement
if (!_paymentService.validatePaymentData(
cotisationId: event.cotisationId,
montant: event.montant,
methodePaiement: event.methodePaiement,
numeroTelephone: event.numeroTelephone,
)) {
emit(PaymentFailure(
cotisationId: event.cotisationId,
paymentId: '',
errorMessage: 'Données de paiement invalides',
errorCode: 'INVALID_DATA',
));
return;
}
// Initier le paiement
final payment = await _paymentService.initiatePayment(
cotisationId: event.cotisationId,
montant: event.montant,
methodePaiement: event.methodePaiement,
numeroTelephone: event.numeroTelephone,
nomPayeur: event.nomPayeur,
emailPayeur: event.emailPayeur,
);
emit(PaymentInProgress(
cotisationId: event.cotisationId,
paymentId: payment.id,
methodePaiement: event.methodePaiement,
montant: event.montant,
));
} catch (e) {
emit(PaymentFailure(
cotisationId: event.cotisationId,
paymentId: '',
errorMessage: e.toString(),
));
}
}
/// Handler pour vérifier le statut d'un paiement
Future<void> _onCheckPaymentStatus(
CheckPaymentStatus event,
Emitter<CotisationsState> emit,
) async {
try {
final payment = await _paymentService.checkPaymentStatus(event.paymentId);
if (payment.isSuccessful) {
// Récupérer la cotisation mise à jour
final cotisation = await _cotisationRepository.getCotisationById(payment.cotisationId);
emit(PaymentSuccess(
cotisationId: payment.cotisationId,
payment: payment,
updatedCotisation: cotisation,
));
// Envoyer notification de succès
await _notificationService.showPaymentConfirmation(cotisation, payment.montant);
} else if (payment.isFailed) {
emit(PaymentFailure(
cotisationId: payment.cotisationId,
paymentId: payment.id,
errorMessage: payment.messageErreur ?? 'Paiement échoué',
));
// Envoyer notification d'échec
final cotisation = await _cotisationRepository.getCotisationById(payment.cotisationId);
await _notificationService.showPaymentFailure(cotisation, payment.messageErreur ?? 'Erreur inconnue');
}
} catch (e) {
emit(CotisationsError('Erreur lors de la vérification du paiement: ${e.toString()}'));
}
}
/// Handler pour annuler un paiement
Future<void> _onCancelPayment(
CancelPayment event,
Emitter<CotisationsState> emit,
) async {
try {
final cancelled = await _paymentService.cancelPayment(event.paymentId);
if (cancelled) {
emit(PaymentCancelled(
cotisationId: event.cotisationId,
paymentId: event.paymentId,
));
} else {
emit(const CotisationsError('Impossible d\'annuler le paiement'));
}
} catch (e) {
emit(CotisationsError('Erreur lors de l\'annulation du paiement: ${e.toString()}'));
}
}
/// Handler pour programmer les notifications
Future<void> _onScheduleNotifications(
ScheduleNotifications event,
Emitter<CotisationsState> emit,
) async {
try {
await _notificationService.scheduleAllCotisationsNotifications(event.cotisations);
emit(NotificationsScheduled(
notificationsCount: event.cotisations.length * 2,
cotisationIds: event.cotisations.map((c) => c.id).toList(),
));
} catch (e) {
emit(CotisationsError('Erreur lors de la programmation des notifications: ${e.toString()}'));
}
}
/// Handler pour synchroniser avec le serveur
Future<void> _onSyncWithServer(
SyncWithServer event,
Emitter<CotisationsState> emit,
) async {
try {
emit(const SyncInProgress('Synchronisation en cours...'));
// Recharger les données
final cotisations = await _cotisationRepository.getCotisations();
emit(SyncCompleted(
itemsSynced: cotisations.length,
syncTime: DateTime.now(),
));
// Émettre l'état chargé avec les nouvelles données
emit(CotisationsLoaded(
cotisations: cotisations,
filteredCotisations: cotisations,
));
} catch (e) {
emit(CotisationsError('Erreur lors de la synchronisation: ${e.toString()}'));
}
}
/// Handler pour appliquer des filtres avancés
Future<void> _onApplyAdvancedFilters(
ApplyAdvancedFilters event,
Emitter<CotisationsState> emit,
) async {
try {
emit(const CotisationsLoading());
final cotisations = await _cotisationRepository.rechercherCotisations(
membreId: event.filters['membreId'],
statut: event.filters['statut'],
typeCotisation: event.filters['typeCotisation'],
annee: event.filters['annee'],
mois: event.filters['mois'],
);
emit(CotisationsSearchResults(
cotisations: cotisations,
searchCriteria: event.filters,
));
} catch (e) {
emit(CotisationsError('Erreur lors de l\'application des filtres: ${e.toString()}'));
}
}
/// Handler pour exporter les cotisations
Future<void> _onExportCotisations(
ExportCotisations event,
Emitter<CotisationsState> emit,
) async {
try {
final cotisations = event.cotisations ?? [];
emit(ExportInProgress(
format: event.format,
totalItems: cotisations.length,
));
// TODO: Implémenter l'export réel selon le format
await Future.delayed(const Duration(seconds: 2)); // Simulation
emit(ExportCompleted(
format: event.format,
filePath: '/storage/emulated/0/Download/cotisations.${event.format}',
itemsExported: cotisations.length,
));
} catch (e) {
emit(CotisationsError('Erreur lors de l\'export: ${e.toString()}'));
}
}
}

View File

@@ -204,3 +204,97 @@ class SortCotisations extends CotisationsEvent {
@override
List<Object?> get props => [sortBy, ascending];
}
/// Événement pour initier un paiement
class InitiatePayment extends CotisationsEvent {
final String cotisationId;
final double montant;
final String methodePaiement;
final String numeroTelephone;
final String? nomPayeur;
final String? emailPayeur;
const InitiatePayment({
required this.cotisationId,
required this.montant,
required this.methodePaiement,
required this.numeroTelephone,
this.nomPayeur,
this.emailPayeur,
});
@override
List<Object?> get props => [
cotisationId,
montant,
methodePaiement,
numeroTelephone,
nomPayeur,
emailPayeur,
];
}
/// Événement pour vérifier le statut d'un paiement
class CheckPaymentStatus extends CotisationsEvent {
final String paymentId;
const CheckPaymentStatus(this.paymentId);
@override
List<Object?> get props => [paymentId];
}
/// Événement pour annuler un paiement
class CancelPayment extends CotisationsEvent {
final String paymentId;
final String cotisationId;
const CancelPayment({
required this.paymentId,
required this.cotisationId,
});
@override
List<Object?> get props => [paymentId, cotisationId];
}
/// Événement pour programmer des notifications
class ScheduleNotifications extends CotisationsEvent {
final List<CotisationModel> cotisations;
const ScheduleNotifications(this.cotisations);
@override
List<Object?> get props => [cotisations];
}
/// Événement pour synchroniser avec le serveur
class SyncWithServer extends CotisationsEvent {
final bool forceSync;
const SyncWithServer({this.forceSync = false});
@override
List<Object?> get props => [forceSync];
}
/// Événement pour appliquer des filtres avancés
class ApplyAdvancedFilters extends CotisationsEvent {
final Map<String, dynamic> filters;
const ApplyAdvancedFilters(this.filters);
@override
List<Object?> get props => [filters];
}
/// Événement pour exporter des données
class ExportCotisations extends CotisationsEvent {
final String format; // 'pdf', 'excel', 'csv'
final List<CotisationModel>? cotisations;
const ExportCotisations(this.format, {this.cotisations});
@override
List<Object?> get props => [format, cotisations];
}

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/models/payment_model.dart';
/// États du BLoC des cotisations
abstract class CotisationsState extends Equatable {
@@ -245,3 +246,137 @@ class CotisationsSearchResults extends CotisationsState {
@override
List<Object?> get props => [cotisations, searchCriteria, hasReachedMax, currentPage];
}
/// État pour un paiement en cours
class PaymentInProgress extends CotisationsState {
final String cotisationId;
final String paymentId;
final String methodePaiement;
final double montant;
const PaymentInProgress({
required this.cotisationId,
required this.paymentId,
required this.methodePaiement,
required this.montant,
});
@override
List<Object?> get props => [cotisationId, paymentId, methodePaiement, montant];
}
/// État pour un paiement réussi
class PaymentSuccess extends CotisationsState {
final String cotisationId;
final PaymentModel payment;
final CotisationModel updatedCotisation;
const PaymentSuccess({
required this.cotisationId,
required this.payment,
required this.updatedCotisation,
});
@override
List<Object?> get props => [cotisationId, payment, updatedCotisation];
}
/// État pour un paiement échoué
class PaymentFailure extends CotisationsState {
final String cotisationId;
final String paymentId;
final String errorMessage;
final String? errorCode;
const PaymentFailure({
required this.cotisationId,
required this.paymentId,
required this.errorMessage,
this.errorCode,
});
@override
List<Object?> get props => [cotisationId, paymentId, errorMessage, errorCode];
}
/// État pour un paiement annulé
class PaymentCancelled extends CotisationsState {
final String cotisationId;
final String paymentId;
const PaymentCancelled({
required this.cotisationId,
required this.paymentId,
});
@override
List<Object?> get props => [cotisationId, paymentId];
}
/// État pour la synchronisation en cours
class SyncInProgress extends CotisationsState {
final String message;
const SyncInProgress(this.message);
@override
List<Object?> get props => [message];
}
/// État pour la synchronisation terminée
class SyncCompleted extends CotisationsState {
final int itemsSynced;
final DateTime syncTime;
const SyncCompleted({
required this.itemsSynced,
required this.syncTime,
});
@override
List<Object?> get props => [itemsSynced, syncTime];
}
/// État pour l'export en cours
class ExportInProgress extends CotisationsState {
final String format;
final int totalItems;
const ExportInProgress({
required this.format,
required this.totalItems,
});
@override
List<Object?> get props => [format, totalItems];
}
/// État pour l'export terminé
class ExportCompleted extends CotisationsState {
final String format;
final String filePath;
final int itemsExported;
const ExportCompleted({
required this.format,
required this.filePath,
required this.itemsExported,
});
@override
List<Object?> get props => [format, filePath, itemsExported];
}
/// État pour les notifications programmées
class NotificationsScheduled extends CotisationsState {
final int notificationsCount;
final List<String> cotisationIds;
const NotificationsScheduled({
required this.notificationsCount,
required this.cotisationIds,
});
@override
List<Object?> get props => [notificationsCount, cotisationIds];
}

View File

@@ -0,0 +1,708 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/models/payment_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../../../../shared/widgets/buttons/primary_button.dart';
import '../bloc/cotisations_bloc.dart';
import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
import '../widgets/payment_method_selector.dart';
import '../widgets/payment_form_widget.dart';
import '../widgets/cotisation_timeline_widget.dart';
/// Page de détail d'une cotisation
class CotisationDetailPage extends StatefulWidget {
final CotisationModel cotisation;
const CotisationDetailPage({
super.key,
required this.cotisation,
});
@override
State<CotisationDetailPage> createState() => _CotisationDetailPageState();
}
class _CotisationDetailPageState extends State<CotisationDetailPage>
with TickerProviderStateMixin {
late final CotisationsBloc _cotisationsBloc;
late final TabController _tabController;
late final AnimationController _animationController;
late final Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_cotisationsBloc = getIt<CotisationsBloc>();
_tabController = TabController(length: 3, vsync: this);
_animationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.forward();
}
@override
void dispose() {
_tabController.dispose();
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: BlocListener<CotisationsBloc, CotisationsState>(
listener: (context, state) {
if (state is PaymentSuccess) {
_showPaymentSuccessDialog(state);
} else if (state is PaymentFailure) {
_showPaymentErrorDialog(state);
} else if (state is PaymentInProgress) {
_showPaymentProgressDialog(state);
}
},
child: FadeTransition(
opacity: _fadeAnimation,
child: CustomScrollView(
slivers: [
_buildAppBar(),
SliverToBoxAdapter(
child: Column(
children: [
_buildStatusCard(),
const SizedBox(height: 16),
_buildTabSection(),
],
),
),
],
),
),
),
bottomNavigationBar: _buildBottomActions(),
),
);
}
Widget _buildAppBar() {
return SliverAppBar(
expandedHeight: 200,
pinned: true,
backgroundColor: _getStatusColor(),
flexibleSpace: FlexibleSpaceBar(
title: Text(
widget.cotisation.typeCotisation,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_getStatusColor(),
_getStatusColor().withOpacity(0.8),
],
),
),
child: Stack(
children: [
Positioned(
right: -50,
top: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withOpacity(0.1),
),
),
),
Positioned(
right: 20,
bottom: 20,
child: Icon(
_getStatusIcon(),
size: 80,
color: Colors.white.withOpacity(0.3),
),
),
],
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.share, color: Colors.white),
onPressed: _shareReceipt,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white),
onSelected: _handleMenuAction,
itemBuilder: (context) => [
const PopupMenuItem(
value: 'export',
child: Row(
children: [
Icon(Icons.download),
SizedBox(width: 8),
Text('Exporter'),
],
),
),
const PopupMenuItem(
value: 'print',
child: Row(
children: [
Icon(Icons.print),
SizedBox(width: 8),
Text('Imprimer'),
],
),
),
const PopupMenuItem(
value: 'history',
child: Row(
children: [
Icon(Icons.history),
SizedBox(width: 8),
Text('Historique'),
],
),
),
],
),
],
);
}
Widget _buildStatusCard() {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Montant à payer',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'${widget.cotisation.montantDu.toStringAsFixed(0)} XOF',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getStatusColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getStatusIcon(),
size: 16,
color: _getStatusColor(),
),
const SizedBox(width: 4),
Text(
widget.cotisation.statut,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _getStatusColor(),
),
),
],
),
),
],
),
const SizedBox(height: 20),
_buildInfoRow('Membre', widget.cotisation.nomMembre ?? 'N/A'),
_buildInfoRow('Période', _formatPeriode()),
_buildInfoRow('Échéance', _formatDate(widget.cotisation.dateEcheance)),
if (widget.cotisation.montantPaye > 0)
_buildInfoRow('Montant payé', '${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF'),
if (widget.cotisation.isEnRetard)
_buildInfoRow('Retard', '${widget.cotisation.joursRetard} jours', isWarning: true),
],
),
);
}
Widget _buildInfoRow(String label, String value, {bool isWarning = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isWarning ? AppTheme.warningColor : AppTheme.textPrimary,
),
),
],
),
);
}
Widget _buildTabSection() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
TabBar(
controller: _tabController,
labelColor: AppTheme.primaryColor,
unselectedLabelColor: AppTheme.textSecondary,
indicatorColor: AppTheme.primaryColor,
tabs: const [
Tab(text: 'Détails', icon: Icon(Icons.info_outline)),
Tab(text: 'Paiement', icon: Icon(Icons.payment)),
Tab(text: 'Historique', icon: Icon(Icons.history)),
],
),
SizedBox(
height: 400,
child: TabBarView(
controller: _tabController,
children: [
_buildDetailsTab(),
_buildPaymentTab(),
_buildHistoryTab(),
],
),
),
],
),
);
}
Widget _buildDetailsTab() {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailSection('Informations générales', [
_buildDetailItem('Type', widget.cotisation.typeCotisation),
_buildDetailItem('Référence', widget.cotisation.numeroReference),
_buildDetailItem('Date création', _formatDate(widget.cotisation.dateCreation)),
_buildDetailItem('Statut', widget.cotisation.statut),
]),
const SizedBox(height: 20),
_buildDetailSection('Montants', [
_buildDetailItem('Montant dû', '${widget.cotisation.montantDu.toStringAsFixed(0)} XOF'),
_buildDetailItem('Montant payé', '${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF'),
_buildDetailItem('Reste à payer', '${(widget.cotisation.montantDu - widget.cotisation.montantPaye).toStringAsFixed(0)} XOF'),
]),
if (widget.cotisation.description?.isNotEmpty == true) ...[
const SizedBox(height: 20),
_buildDetailSection('Description', [
Text(
widget.cotisation.description!,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
]),
],
],
),
);
}
Widget _buildPaymentTab() {
if (widget.cotisation.isEntierementPayee) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle,
size: 64,
color: AppTheme.successColor,
),
SizedBox(height: 16),
Text(
'Cotisation entièrement payée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.successColor,
),
),
],
),
);
}
return BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
if (state is PaymentInProgress) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Traitement du paiement en cours...'),
],
),
);
}
return PaymentFormWidget(
cotisation: widget.cotisation,
onPaymentInitiated: (paymentData) {
_cotisationsBloc.add(InitiatePayment(
cotisationId: widget.cotisation.id,
montant: paymentData['montant'],
methodePaiement: paymentData['methodePaiement'],
numeroTelephone: paymentData['numeroTelephone'],
nomPayeur: paymentData['nomPayeur'],
emailPayeur: paymentData['emailPayeur'],
));
},
);
},
);
}
Widget _buildHistoryTab() {
return CotisationTimelineWidget(cotisation: widget.cotisation);
}
Widget _buildDetailSection(String title, List<Widget> children) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
...children,
],
);
}
Widget _buildDetailItem(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
],
),
);
}
Widget _buildBottomActions() {
if (widget.cotisation.isEntierementPayee) {
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 10,
offset: Offset(0, -2),
),
],
),
child: PrimaryButton(
text: 'Télécharger le reçu',
icon: Icons.download,
onPressed: _downloadReceipt,
),
);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 10,
offset: Offset(0, -2),
),
],
),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _scheduleReminder,
icon: const Icon(Icons.notifications),
label: const Text('Rappel'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: PrimaryButton(
text: 'Payer maintenant',
icon: Icons.payment,
onPressed: () {
_tabController.animateTo(1); // Aller à l'onglet paiement
},
),
),
],
),
);
}
// Méthodes utilitaires
Color _getStatusColor() {
switch (widget.cotisation.statut.toLowerCase()) {
case 'payee':
return AppTheme.successColor;
case 'en_retard':
return AppTheme.errorColor;
case 'en_attente':
return AppTheme.warningColor;
default:
return AppTheme.primaryColor;
}
}
IconData _getStatusIcon() {
switch (widget.cotisation.statut.toLowerCase()) {
case 'payee':
return Icons.check_circle;
case 'en_retard':
return Icons.warning;
case 'en_attente':
return Icons.schedule;
default:
return Icons.payment;
}
}
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
String _formatPeriode() {
return '${widget.cotisation.mois}/${widget.cotisation.annee}';
}
// Actions
void _shareReceipt() {
// TODO: Implémenter le partage
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Partage - En cours de développement')),
);
}
void _handleMenuAction(String action) {
switch (action) {
case 'export':
_exportReceipt();
break;
case 'print':
_printReceipt();
break;
case 'history':
_showFullHistory();
break;
}
}
void _exportReceipt() {
_cotisationsBloc.add(ExportCotisations('pdf', cotisations: [widget.cotisation]));
}
void _printReceipt() {
// TODO: Implémenter l'impression
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impression - En cours de développement')),
);
}
void _showFullHistory() {
// TODO: Naviguer vers l'historique complet
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Historique complet - En cours de développement')),
);
}
void _downloadReceipt() {
_exportReceipt();
}
void _scheduleReminder() {
_cotisationsBloc.add(ScheduleNotifications([widget.cotisation]));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Rappel programmé avec succès'),
backgroundColor: AppTheme.successColor,
),
);
}
// Dialogs
void _showPaymentSuccessDialog(PaymentSuccess state) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.check_circle, color: AppTheme.successColor),
SizedBox(width: 8),
Text('Paiement réussi'),
],
),
content: Text('Votre paiement de ${state.payment.montant.toStringAsFixed(0)} XOF a été confirmé.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).pop(); // Retour à la liste
},
child: const Text('OK'),
),
],
),
);
}
void _showPaymentErrorDialog(PaymentFailure state) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.error, color: AppTheme.errorColor),
SizedBox(width: 8),
Text('Échec du paiement'),
],
),
content: Text(state.errorMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
void _showPaymentProgressDialog(PaymentInProgress state) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text('Traitement du paiement de ${state.montant.toStringAsFixed(0)} XOF...'),
const SizedBox(height: 8),
Text('Méthode: ${state.methodePaiement}'),
],
),
),
);
}
}

View File

@@ -8,6 +8,8 @@ import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
import '../widgets/cotisation_card.dart';
import '../widgets/cotisations_stats_card.dart';
import 'cotisation_detail_page.dart';
import 'cotisations_search_page.dart';
/// Page principale pour la liste des cotisations
class CotisationsListPage extends StatefulWidget {
@@ -155,13 +157,23 @@ class _CotisationsListPageState extends State<CotisationsListPage> {
IconButton(
icon: const Icon(Icons.search, color: Colors.white),
onPressed: () {
// TODO: Implémenter la recherche
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CotisationsSearchPage(),
),
);
},
),
IconButton(
icon: const Icon(Icons.filter_list, color: Colors.white),
onPressed: () {
// TODO: Implémenter les filtres
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CotisationsSearchPage(),
),
);
},
),
],
@@ -264,14 +276,22 @@ class _CotisationsListPageState extends State<CotisationsListPage> {
child: CotisationCard(
cotisation: cotisation,
onTap: () {
// TODO: Naviguer vers le détail
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CotisationDetailPage(
cotisation: cotisation,
),
),
);
},
onPay: () {
// TODO: Implémenter le paiement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Paiement - En cours de développement'),
backgroundColor: AppTheme.successColor,
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CotisationDetailPage(
cotisation: cotisation,
),
),
);
},

View File

@@ -0,0 +1,498 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../../../../shared/widgets/buttons/primary_button.dart';
import '../bloc/cotisations_bloc.dart';
import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
import '../widgets/cotisation_card.dart';
import 'cotisation_detail_page.dart';
/// Page de recherche et filtrage des cotisations
class CotisationsSearchPage extends StatefulWidget {
const CotisationsSearchPage({super.key});
@override
State<CotisationsSearchPage> createState() => _CotisationsSearchPageState();
}
class _CotisationsSearchPageState extends State<CotisationsSearchPage>
with TickerProviderStateMixin {
late final CotisationsBloc _cotisationsBloc;
late final TabController _tabController;
late final AnimationController _animationController;
final _searchController = TextEditingController();
final _scrollController = ScrollController();
String? _selectedStatut;
String? _selectedType;
int? _selectedAnnee;
int? _selectedMois;
bool _showAdvancedFilters = false;
@override
void initState() {
super.initState();
_cotisationsBloc = getIt<CotisationsBloc>();
_tabController = TabController(length: 4, vsync: this);
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scrollController.addListener(_onScroll);
_animationController.forward();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose();
_tabController.dispose();
_animationController.dispose();
super.dispose();
}
void _onScroll() {
if (_isBottom) {
final currentState = _cotisationsBloc.state;
if (currentState is CotisationsSearchResults && !currentState.hasReachedMax) {
_performSearch(page: currentState.currentPage + 1);
}
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text('Recherche'),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
bottom: TabBar(
controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
indicatorColor: Colors.white,
tabs: const [
Tab(text: 'Toutes', icon: Icon(Icons.list)),
Tab(text: 'En attente', icon: Icon(Icons.schedule)),
Tab(text: 'En retard', icon: Icon(Icons.warning)),
Tab(text: 'Payées', icon: Icon(Icons.check_circle)),
],
onTap: (index) => _onTabChanged(index),
),
),
body: Column(
children: [
_buildSearchHeader(),
if (_showAdvancedFilters) _buildAdvancedFilters(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildSearchResults(),
_buildSearchResults(statut: 'EN_ATTENTE'),
_buildSearchResults(statut: 'EN_RETARD'),
_buildSearchResults(statut: 'PAYEE'),
],
),
),
],
),
),
);
}
Widget _buildSearchHeader() {
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
children: [
// Barre de recherche
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par nom, référence...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_performSearch();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: AppTheme.backgroundLight,
),
onChanged: (value) {
setState(() {});
_performSearch();
},
),
const SizedBox(height: 12),
// Boutons d'action
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_showAdvancedFilters = !_showAdvancedFilters;
});
if (_showAdvancedFilters) {
_animationController.forward();
} else {
_animationController.reverse();
}
},
icon: Icon(_showAdvancedFilters ? Icons.expand_less : Icons.tune),
label: Text(_showAdvancedFilters ? 'Masquer filtres' : 'Filtres avancés'),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _clearAllFilters,
icon: const Icon(Icons.clear_all),
label: const Text('Effacer'),
),
],
),
],
),
);
}
Widget _buildAdvancedFilters() {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: _showAdvancedFilters ? null : 0,
child: Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: AppTheme.borderLight),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Filtres avancés',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
// Grille de filtres
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 3,
children: [
_buildFilterDropdown(
'Type',
_selectedType,
['Mensuelle', 'Annuelle', 'Exceptionnelle', 'Adhésion'],
(value) => setState(() => _selectedType = value),
),
_buildFilterDropdown(
'Année',
_selectedAnnee?.toString(),
List.generate(5, (i) => (DateTime.now().year - i).toString()),
(value) => setState(() => _selectedAnnee = int.tryParse(value ?? '')),
),
],
),
const SizedBox(height: 16),
// Bouton d'application des filtres
SizedBox(
width: double.infinity,
child: PrimaryButton(
text: 'Appliquer les filtres',
onPressed: _applyAdvancedFilters,
),
),
],
),
),
);
}
Widget _buildFilterDropdown(
String label,
String? value,
List<String> items,
Function(String?) onChanged,
) {
return DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
DropdownMenuItem<String>(
value: null,
child: Text('Tous les ${label.toLowerCase()}s'),
),
...items.map((item) => DropdownMenuItem<String>(
value: item,
child: Text(item),
)),
],
onChanged: onChanged,
);
}
Widget _buildSearchResults({String? statut}) {
return BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
if (state is CotisationsLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is CotisationsError) {
return _buildErrorState(state);
}
if (state is CotisationsSearchResults) {
final filteredResults = statut != null
? state.cotisations.where((c) => c.statut == statut).toList()
: state.cotisations;
if (filteredResults.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: () async => _performSearch(refresh: true),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: filteredResults.length + (state.hasReachedMax ? 0 : 1),
itemBuilder: (context, index) {
if (index >= filteredResults.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final cotisation = filteredResults[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: CotisationCard(
cotisation: cotisation,
onTap: () => _navigateToDetail(cotisation),
onPay: () => _navigateToDetail(cotisation),
),
);
},
),
);
}
return _buildInitialState();
},
);
}
Widget _buildInitialState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: AppTheme.textHint,
),
SizedBox(height: 16),
Text(
'Recherchez des cotisations',
style: TextStyle(
fontSize: 18,
color: AppTheme.textSecondary,
),
),
SizedBox(height: 8),
Text(
'Utilisez la barre de recherche ou les filtres',
style: TextStyle(
fontSize: 14,
color: AppTheme.textHint,
),
),
],
),
);
}
Widget _buildEmptyState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: AppTheme.textHint,
),
SizedBox(height: 16),
Text(
'Aucun résultat trouvé',
style: TextStyle(
fontSize: 18,
color: AppTheme.textSecondary,
),
),
SizedBox(height: 8),
Text(
'Essayez de modifier vos critères de recherche',
style: TextStyle(
fontSize: 14,
color: AppTheme.textHint,
),
),
],
),
);
}
Widget _buildErrorState(CotisationsError state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
Text(
'Erreur de recherche',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
state.message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 24),
PrimaryButton(
text: 'Réessayer',
onPressed: () => _performSearch(refresh: true),
),
],
),
);
}
// Actions
void _onTabChanged(int index) {
_performSearch(refresh: true);
}
void _performSearch({int page = 0, bool refresh = false}) {
final query = _searchController.text.trim();
if (query.isEmpty && !_hasActiveFilters()) {
return;
}
final filters = <String, dynamic>{
if (query.isNotEmpty) 'query': query,
if (_selectedStatut != null) 'statut': _selectedStatut,
if (_selectedType != null) 'typeCotisation': _selectedType,
if (_selectedAnnee != null) 'annee': _selectedAnnee,
if (_selectedMois != null) 'mois': _selectedMois,
};
_cotisationsBloc.add(ApplyAdvancedFilters(filters));
}
void _applyAdvancedFilters() {
_performSearch(refresh: true);
}
void _clearAllFilters() {
setState(() {
_searchController.clear();
_selectedStatut = null;
_selectedType = null;
_selectedAnnee = null;
_selectedMois = null;
});
_cotisationsBloc.add(const ResetCotisationsState());
}
bool _hasActiveFilters() {
return _selectedStatut != null ||
_selectedType != null ||
_selectedAnnee != null ||
_selectedMois != null;
}
void _navigateToDetail(CotisationModel cotisation) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CotisationDetailPage(cotisation: cotisation),
),
);
}
}

View File

@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/animations/loading_animations.dart';
import 'cotisation_card.dart';
/// Widget animé pour afficher une liste de cotisations avec animations d'apparition
class AnimatedCotisationList extends StatefulWidget {
final List<CotisationModel> cotisations;
final Function(CotisationModel)? onCotisationTap;
final bool isLoading;
final VoidCallback? onRefresh;
final ScrollController? scrollController;
const AnimatedCotisationList({
super.key,
required this.cotisations,
this.onCotisationTap,
this.isLoading = false,
this.onRefresh,
this.scrollController,
});
@override
State<AnimatedCotisationList> createState() => _AnimatedCotisationListState();
}
class _AnimatedCotisationListState extends State<AnimatedCotisationList>
with TickerProviderStateMixin {
late AnimationController _listController;
List<AnimationController> _itemControllers = [];
List<Animation<double>> _itemAnimations = [];
List<Animation<Offset>> _slideAnimations = [];
@override
void initState() {
super.initState();
_initializeAnimations();
}
@override
void didUpdateWidget(AnimatedCotisationList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.cotisations.length != oldWidget.cotisations.length) {
_updateAnimations();
}
}
@override
void dispose() {
_listController.dispose();
for (final controller in _itemControllers) {
controller.dispose();
}
super.dispose();
}
void _initializeAnimations() {
_listController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_updateAnimations();
_listController.forward();
}
void _updateAnimations() {
// Dispose des anciens controllers s'ils existent
if (_itemControllers.isNotEmpty) {
for (final controller in _itemControllers) {
controller.dispose();
}
}
// Créer de nouveaux controllers pour chaque élément
_itemControllers = List.generate(
widget.cotisations.length,
(index) => AnimationController(
duration: Duration(milliseconds: 400 + (index * 80)),
vsync: this,
),
);
// Animations de fade et scale
_itemAnimations = _itemControllers.map((controller) {
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOutCubic,
),
);
}).toList();
// Animations de slide depuis la gauche
_slideAnimations = _itemControllers.map((controller) {
return Tween<Offset>(
begin: const Offset(-0.3, 0),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOutCubic,
),
);
}).toList();
// Démarrer les animations avec un délai progressif
for (int i = 0; i < _itemControllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 120), () {
if (mounted) {
_itemControllers[i].forward();
}
});
}
}
@override
Widget build(BuildContext context) {
if (widget.isLoading && widget.cotisations.isEmpty) {
return _buildLoadingState();
}
if (widget.cotisations.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: () async {
widget.onRefresh?.call();
await Future.delayed(const Duration(milliseconds: 500));
},
child: ListView.builder(
controller: widget.scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: widget.cotisations.length + (widget.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= widget.cotisations.length) {
return _buildLoadingIndicator();
}
return _buildAnimatedItem(index);
},
),
);
}
Widget _buildAnimatedItem(int index) {
final cotisation = widget.cotisations[index];
if (index >= _itemAnimations.length) {
// Fallback pour les nouveaux éléments
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: CotisationCard(
cotisation: cotisation,
onTap: () => widget.onCotisationTap?.call(cotisation),
),
);
}
return AnimatedBuilder(
animation: _itemAnimations[index],
builder: (context, child) {
return SlideTransition(
position: _slideAnimations[index],
child: FadeTransition(
opacity: _itemAnimations[index],
child: Transform.scale(
scale: 0.9 + (0.1 * _itemAnimations[index].value),
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: CotisationCard(
cotisation: cotisation,
onTap: () => widget.onCotisationTap?.call(cotisation),
),
),
),
),
);
},
);
}
Widget _buildLoadingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LoadingAnimations.pulse(),
const SizedBox(height: 24),
const Text(
'Chargement des cotisations...',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.payment_outlined,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 24),
Text(
'Aucune cotisation trouvée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Les cotisations apparaîtront ici',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
Widget _buildLoadingIndicator() {
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: LoadingAnimations.spinner(),
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
@@ -41,7 +42,10 @@ class CotisationCard extends StatelessWidget {
),
),
child: InkWell(
onTap: onTap,
onTap: () {
HapticFeedback.lightImpact();
onTap?.call();
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
@@ -71,7 +75,10 @@ class CotisationCard extends StatelessWidget {
// Actions
if (cotisation.statut == 'EN_ATTENTE' || cotisation.statut == 'EN_RETARD')
IconButton(
onPressed: onPay,
onPressed: () {
HapticFeedback.lightImpact();
onPay?.call();
},
icon: const Icon(Icons.payment, size: 20),
color: AppTheme.successColor,
tooltip: 'Payer',

View File

@@ -0,0 +1,417 @@
import 'package:flutter/material.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Widget d'affichage de la timeline d'une cotisation
class CotisationTimelineWidget extends StatefulWidget {
final CotisationModel cotisation;
const CotisationTimelineWidget({
super.key,
required this.cotisation,
});
@override
State<CotisationTimelineWidget> createState() => _CotisationTimelineWidgetState();
}
class _CotisationTimelineWidgetState extends State<CotisationTimelineWidget>
with TickerProviderStateMixin {
late final AnimationController _animationController;
late final List<Animation<double>> _itemAnimations;
List<TimelineEvent> _timelineEvents = [];
@override
void initState() {
super.initState();
_generateTimelineEvents();
_animationController = AnimationController(
duration: Duration(milliseconds: 300 * _timelineEvents.length),
vsync: this,
);
_itemAnimations = List.generate(
_timelineEvents.length,
(index) => Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _animationController,
curve: Interval(
index / _timelineEvents.length,
(index + 1) / _timelineEvents.length,
curve: Curves.easeOutCubic,
),
),
),
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _generateTimelineEvents() {
_timelineEvents = [
TimelineEvent(
title: 'Cotisation créée',
description: 'Cotisation ${widget.cotisation.typeCotisation} créée pour ${widget.cotisation.nomMembre}',
date: widget.cotisation.dateCreation,
icon: Icons.add_circle,
color: AppTheme.primaryColor,
isCompleted: true,
),
];
// Ajouter l'événement d'échéance
final now = DateTime.now();
final isOverdue = widget.cotisation.dateEcheance.isBefore(now);
_timelineEvents.add(
TimelineEvent(
title: isOverdue ? 'Échéance dépassée' : 'Échéance prévue',
description: 'Date limite de paiement: ${_formatDate(widget.cotisation.dateEcheance)}',
date: widget.cotisation.dateEcheance,
icon: isOverdue ? Icons.warning : Icons.schedule,
color: isOverdue ? AppTheme.errorColor : AppTheme.warningColor,
isCompleted: isOverdue,
isWarning: isOverdue,
),
);
// Ajouter les événements de paiement (simulés)
if (widget.cotisation.montantPaye > 0) {
_timelineEvents.add(
TimelineEvent(
title: 'Paiement partiel reçu',
description: 'Montant: ${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF',
date: widget.cotisation.dateCreation.add(const Duration(days: 5)), // Simulé
icon: Icons.payment,
color: AppTheme.successColor,
isCompleted: true,
),
);
}
if (widget.cotisation.isEntierementPayee) {
_timelineEvents.add(
TimelineEvent(
title: 'Paiement complet',
description: 'Cotisation entièrement payée',
date: widget.cotisation.dateCreation.add(const Duration(days: 10)), // Simulé
icon: Icons.check_circle,
color: AppTheme.successColor,
isCompleted: true,
isSuccess: true,
),
);
} else {
// Ajouter les événements futurs
if (!isOverdue) {
_timelineEvents.add(
TimelineEvent(
title: 'Rappel automatique',
description: 'Rappel envoyé 3 jours avant l\'échéance',
date: widget.cotisation.dateEcheance.subtract(const Duration(days: 3)),
icon: Icons.notifications,
color: AppTheme.infoColor,
isCompleted: false,
isFuture: true,
),
);
}
_timelineEvents.add(
TimelineEvent(
title: 'Paiement en attente',
description: 'En attente du paiement complet',
date: DateTime.now(),
icon: Icons.hourglass_empty,
color: AppTheme.textSecondary,
isCompleted: false,
isFuture: true,
),
);
}
// Trier par date
_timelineEvents.sort((a, b) => a.date.compareTo(b.date));
}
@override
Widget build(BuildContext context) {
if (_timelineEvents.isEmpty) {
return const Center(
child: Text(
'Aucun historique disponible',
style: TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
),
),
);
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Historique de la cotisation',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 20),
Expanded(
child: ListView.builder(
itemCount: _timelineEvents.length,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _itemAnimations[index],
builder: (context, child) {
return Transform.translate(
offset: Offset(
0,
50 * (1 - _itemAnimations[index].value),
),
child: Opacity(
opacity: _itemAnimations[index].value,
child: _buildTimelineItem(
_timelineEvents[index],
index,
index == _timelineEvents.length - 1,
),
),
);
},
);
},
),
),
],
),
);
}
Widget _buildTimelineItem(TimelineEvent event, int index, bool isLast) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline indicator
Column(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: event.isCompleted
? event.color
: event.color.withOpacity(0.2),
border: Border.all(
color: event.color,
width: event.isCompleted ? 0 : 2,
),
),
child: Icon(
event.icon,
size: 20,
color: event.isCompleted
? Colors.white
: event.color,
),
),
if (!isLast)
Container(
width: 2,
height: 60,
color: event.isCompleted
? event.color.withOpacity(0.3)
: AppTheme.borderLight,
),
],
),
const SizedBox(width: 16),
// Event content
Expanded(
child: Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _getEventBackgroundColor(event),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: event.color.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
event.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: event.isCompleted
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
),
),
if (event.isSuccess)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Terminé',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.successColor,
),
),
),
if (event.isWarning)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'En retard',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.errorColor,
),
),
),
],
),
const SizedBox(height: 8),
Text(
event.description,
style: TextStyle(
fontSize: 14,
color: event.isCompleted
? AppTheme.textSecondary
: AppTheme.textHint,
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: AppTheme.textHint,
),
const SizedBox(width: 4),
Text(
_formatDateTime(event.date),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
if (event.isFuture) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppTheme.infoColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'À venir',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppTheme.infoColor,
),
),
),
],
],
),
],
),
),
),
],
);
}
Color _getEventBackgroundColor(TimelineEvent event) {
if (event.isSuccess) {
return AppTheme.successColor.withOpacity(0.05);
}
if (event.isWarning) {
return AppTheme.errorColor.withOpacity(0.05);
}
if (event.isFuture) {
return AppTheme.infoColor.withOpacity(0.05);
}
return Colors.white;
}
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
String _formatDateTime(DateTime date) {
return '${_formatDate(date)} à ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
}
/// Modèle pour les événements de la timeline
class TimelineEvent {
final String title;
final String description;
final DateTime date;
final IconData icon;
final Color color;
final bool isCompleted;
final bool isSuccess;
final bool isWarning;
final bool isFuture;
TimelineEvent({
required this.title,
required this.description,
required this.date,
required this.icon,
required this.color,
this.isCompleted = false,
this.isSuccess = false,
this.isWarning = false,
this.isFuture = false,
});
}

View File

@@ -0,0 +1,457 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../../../../shared/widgets/buttons/primary_button.dart';
import 'payment_method_selector.dart';
/// Widget de formulaire de paiement
class PaymentFormWidget extends StatefulWidget {
final CotisationModel cotisation;
final Function(Map<String, dynamic>) onPaymentInitiated;
const PaymentFormWidget({
super.key,
required this.cotisation,
required this.onPaymentInitiated,
});
@override
State<PaymentFormWidget> createState() => _PaymentFormWidgetState();
}
class _PaymentFormWidgetState extends State<PaymentFormWidget>
with TickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _phoneController = TextEditingController();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _amountController = TextEditingController();
late final AnimationController _animationController;
late final Animation<Offset> _slideAnimation;
String? _selectedPaymentMethod;
bool _isProcessing = false;
bool _acceptTerms = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
));
// Initialiser le montant avec le montant restant à payer
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
_amountController.text = remainingAmount.toStringAsFixed(0);
_animationController.forward();
}
@override
void dispose() {
_phoneController.dispose();
_nameController.dispose();
_emailController.dispose();
_amountController.dispose();
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _slideAnimation,
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sélection de la méthode de paiement
PaymentMethodSelector(
selectedMethod: _selectedPaymentMethod,
montant: double.tryParse(_amountController.text) ?? 0,
onMethodSelected: (method) {
setState(() {
_selectedPaymentMethod = method;
});
},
),
if (_selectedPaymentMethod != null) ...[
const SizedBox(height: 24),
_buildPaymentForm(),
],
],
),
),
),
);
}
Widget _buildPaymentForm() {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations de paiement',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
// Montant à payer
_buildAmountField(),
const SizedBox(height: 16),
// Numéro de téléphone (pour Mobile Money)
if (_isMobileMoneyMethod()) ...[
_buildPhoneField(),
const SizedBox(height: 16),
],
// Nom du payeur
_buildNameField(),
const SizedBox(height: 16),
// Email (optionnel)
_buildEmailField(),
const SizedBox(height: 20),
// Conditions d'utilisation
_buildTermsCheckbox(),
const SizedBox(height: 24),
// Bouton de paiement
_buildPaymentButton(),
],
),
);
}
Widget _buildAmountField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Montant à payer',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _amountController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(8),
],
decoration: InputDecoration(
hintText: 'Entrez le montant',
suffixText: 'XOF',
prefixIcon: const Icon(Icons.attach_money),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un montant';
}
final amount = double.tryParse(value);
if (amount == null || amount <= 0) {
return 'Montant invalide';
}
final remaining = widget.cotisation.montantDu - widget.cotisation.montantPaye;
if (amount > remaining) {
return 'Montant supérieur au solde restant (${remaining.toStringAsFixed(0)} XOF)';
}
return null;
},
onChanged: (value) {
setState(() {}); // Recalculer les frais
},
),
],
);
}
Widget _buildPhoneField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Numéro ${_getPaymentMethodName()}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
decoration: InputDecoration(
hintText: 'Ex: 0123456789',
prefixIcon: const Icon(Icons.phone),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre numéro de téléphone';
}
if (value.length < 8) {
return 'Numéro de téléphone invalide';
}
if (!_validatePhoneForMethod(value)) {
return 'Ce numéro n\'est pas compatible avec ${_getPaymentMethodName()}';
}
return null;
},
),
],
);
}
Widget _buildNameField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Nom du payeur',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _nameController,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
hintText: 'Entrez votre nom complet',
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Veuillez entrer votre nom';
}
if (value.trim().length < 2) {
return 'Nom trop court';
}
return null;
},
),
],
);
}
Widget _buildEmailField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Email (optionnel)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: 'exemple@email.com',
prefixIcon: const Icon(Icons.email),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2),
),
),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Email invalide';
}
}
return null;
},
),
],
);
}
Widget _buildTermsCheckbox() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: _acceptTerms,
onChanged: (value) {
setState(() {
_acceptTerms = value ?? false;
});
},
activeColor: AppTheme.primaryColor,
),
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_acceptTerms = !_acceptTerms;
});
},
child: const Text(
'J\'accepte les conditions d\'utilisation et la politique de confidentialité',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
),
),
],
);
}
Widget _buildPaymentButton() {
return SizedBox(
width: double.infinity,
child: PrimaryButton(
text: _isProcessing
? 'Traitement en cours...'
: 'Confirmer le paiement',
icon: _isProcessing ? null : Icons.payment,
onPressed: _canProceedPayment() ? _processPayment : null,
isLoading: _isProcessing,
),
);
}
bool _canProceedPayment() {
return _selectedPaymentMethod != null &&
_acceptTerms &&
!_isProcessing &&
_amountController.text.isNotEmpty;
}
bool _isMobileMoneyMethod() {
return _selectedPaymentMethod == 'ORANGE_MONEY' ||
_selectedPaymentMethod == 'WAVE' ||
_selectedPaymentMethod == 'MOOV_MONEY';
}
String _getPaymentMethodName() {
switch (_selectedPaymentMethod) {
case 'ORANGE_MONEY':
return 'Orange Money';
case 'WAVE':
return 'Wave';
case 'MOOV_MONEY':
return 'Moov Money';
case 'CARTE_BANCAIRE':
return 'Carte bancaire';
default:
return 'Paiement';
}
}
bool _validatePhoneForMethod(String phone) {
final cleanNumber = phone.replaceAll(RegExp(r'[^\d]'), '');
switch (_selectedPaymentMethod) {
case 'ORANGE_MONEY':
// Orange: 07, 08, 09
return RegExp(r'^(225)?(0[789])\d{8}$').hasMatch(cleanNumber);
case 'WAVE':
// Wave accepte tous les numéros ivoiriens
return RegExp(r'^(225)?(0[1-9])\d{8}$').hasMatch(cleanNumber);
case 'MOOV_MONEY':
// Moov: 01, 02, 03
return RegExp(r'^(225)?(0[123])\d{8}$').hasMatch(cleanNumber);
default:
return cleanNumber.length >= 8;
}
}
void _processPayment() {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isProcessing = true;
});
// Préparer les données de paiement
final paymentData = {
'montant': double.parse(_amountController.text),
'methodePaiement': _selectedPaymentMethod!,
'numeroTelephone': _phoneController.text,
'nomPayeur': _nameController.text.trim(),
'emailPayeur': _emailController.text.trim().isEmpty
? null
: _emailController.text.trim(),
};
// Déclencher le paiement
widget.onPaymentInitiated(paymentData);
// Simuler un délai de traitement
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
setState(() {
_isProcessing = false;
});
}
});
}
}

View File

@@ -0,0 +1,443 @@
import 'package:flutter/material.dart';
import '../../../../core/services/payment_service.dart';
import '../../../../shared/theme/app_theme.dart';
/// Widget de sélection des méthodes de paiement
class PaymentMethodSelector extends StatefulWidget {
final String? selectedMethod;
final Function(String) onMethodSelected;
final double montant;
const PaymentMethodSelector({
super.key,
this.selectedMethod,
required this.onMethodSelected,
required this.montant,
});
@override
State<PaymentMethodSelector> createState() => _PaymentMethodSelectorState();
}
class _PaymentMethodSelectorState extends State<PaymentMethodSelector>
with TickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<double> _scaleAnimation;
List<PaymentMethod> _paymentMethods = [];
String? _selectedMethod;
@override
void initState() {
super.initState();
_selectedMethod = widget.selectedMethod;
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.elasticOut),
);
_loadPaymentMethods();
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _loadPaymentMethods() {
// En production, ceci viendrait du PaymentService
_paymentMethods = [
PaymentMethod(
id: 'ORANGE_MONEY',
nom: 'Orange Money',
icone: '📱',
couleur: '#FF6600',
description: 'Paiement via Orange Money',
fraisMinimum: 0,
fraisMaximum: 1000,
montantMinimum: 100,
montantMaximum: 1000000,
),
PaymentMethod(
id: 'WAVE',
nom: 'Wave',
icone: '🌊',
couleur: '#00D4FF',
description: 'Paiement via Wave',
fraisMinimum: 0,
fraisMaximum: 500,
montantMinimum: 100,
montantMaximum: 2000000,
),
PaymentMethod(
id: 'MOOV_MONEY',
nom: 'Moov Money',
icone: '💙',
couleur: '#0066CC',
description: 'Paiement via Moov Money',
fraisMinimum: 0,
fraisMaximum: 800,
montantMinimum: 100,
montantMaximum: 1500000,
),
PaymentMethod(
id: 'CARTE_BANCAIRE',
nom: 'Carte bancaire',
icone: '💳',
couleur: '#4CAF50',
description: 'Paiement par carte bancaire',
fraisMinimum: 100,
fraisMaximum: 2000,
montantMinimum: 500,
montantMaximum: 5000000,
),
];
// Filtrer les méthodes disponibles selon le montant
_paymentMethods = _paymentMethods.where((method) {
return widget.montant >= method.montantMinimum &&
widget.montant <= method.montantMaximum;
}).toList();
setState(() {});
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _scaleAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Choisissez votre méthode de paiement',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
if (_paymentMethods.isEmpty)
_buildNoMethodsAvailable()
else
_buildMethodsList(),
if (_selectedMethod != null) ...[
const SizedBox(height: 20),
_buildSelectedMethodInfo(),
],
],
),
);
}
Widget _buildNoMethodsAvailable() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.warningColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.warningColor.withOpacity(0.3),
),
),
child: Column(
children: [
Icon(
Icons.warning_amber,
size: 48,
color: AppTheme.warningColor,
),
const SizedBox(height: 12),
const Text(
'Aucune méthode de paiement disponible',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Le montant de ${widget.montant.toStringAsFixed(0)} XOF ne correspond aux limites d\'aucune méthode de paiement.',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildMethodsList() {
return Column(
children: _paymentMethods.map((method) {
final isSelected = _selectedMethod == method.id;
final fees = _calculateFees(method);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 12),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _selectMethod(method),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? _getMethodColor(method.couleur).withOpacity(0.1)
: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? _getMethodColor(method.couleur)
: AppTheme.borderLight,
width: isSelected ? 2 : 1,
),
boxShadow: isSelected ? [
BoxShadow(
color: _getMethodColor(method.couleur).withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
] : null,
),
child: Row(
children: [
// Icône de la méthode
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: _getMethodColor(method.couleur).withOpacity(0.1),
borderRadius: BorderRadius.circular(25),
),
child: Center(
child: Text(
method.icone,
style: const TextStyle(fontSize: 24),
),
),
),
const SizedBox(width: 16),
// Informations de la méthode
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
method.nom,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isSelected
? _getMethodColor(method.couleur)
: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
method.description,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
if (fees > 0) ...[
const SizedBox(height: 4),
Text(
'Frais: ${fees.toStringAsFixed(0)} XOF',
style: TextStyle(
fontSize: 12,
color: AppTheme.warningColor,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
// Indicateur de sélection
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? _getMethodColor(method.couleur)
: Colors.transparent,
border: Border.all(
color: isSelected
? _getMethodColor(method.couleur)
: AppTheme.borderLight,
width: 2,
),
),
child: isSelected
? const Icon(
Icons.check,
size: 16,
color: Colors.white,
)
: null,
),
],
),
),
),
),
);
}).toList(),
);
}
Widget _buildSelectedMethodInfo() {
final method = _paymentMethods.firstWhere((m) => m.id == _selectedMethod);
final fees = _calculateFees(method);
final total = widget.montant + fees;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _getMethodColor(method.couleur).withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getMethodColor(method.couleur).withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
method.icone,
style: const TextStyle(fontSize: 20),
),
const SizedBox(width: 8),
Text(
'Récapitulatif - ${method.nom}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _getMethodColor(method.couleur),
),
),
],
),
const SizedBox(height: 12),
_buildSummaryRow('Montant', '${widget.montant.toStringAsFixed(0)} XOF'),
if (fees > 0)
_buildSummaryRow('Frais', '${fees.toStringAsFixed(0)} XOF'),
const Divider(),
_buildSummaryRow(
'Total à payer',
'${total.toStringAsFixed(0)} XOF',
isTotal: true,
),
],
),
);
}
Widget _buildSummaryRow(String label, String value, {bool isTotal = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: isTotal ? 16 : 14,
fontWeight: isTotal ? FontWeight.bold : FontWeight.normal,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: TextStyle(
fontSize: isTotal ? 16 : 14,
fontWeight: FontWeight.bold,
color: isTotal ? AppTheme.textPrimary : AppTheme.textSecondary,
),
),
],
),
);
}
void _selectMethod(PaymentMethod method) {
setState(() {
_selectedMethod = method.id;
});
widget.onMethodSelected(method.id);
// Animation de feedback
_animationController.reset();
_animationController.forward();
}
double _calculateFees(PaymentMethod method) {
// Simulation du calcul des frais
switch (method.id) {
case 'ORANGE_MONEY':
return _calculateOrangeMoneyFees(widget.montant);
case 'WAVE':
return _calculateWaveFees(widget.montant);
case 'MOOV_MONEY':
return _calculateMoovMoneyFees(widget.montant);
case 'CARTE_BANCAIRE':
return _calculateCardFees(widget.montant);
default:
return 0.0;
}
}
double _calculateOrangeMoneyFees(double montant) {
if (montant <= 1000) return 0;
if (montant <= 5000) return 25;
if (montant <= 10000) return 50;
if (montant <= 25000) return 100;
if (montant <= 50000) return 200;
return montant * 0.005; // 0.5%
}
double _calculateWaveFees(double montant) {
if (montant <= 2000) return 0;
if (montant <= 10000) return 25;
if (montant <= 50000) return 100;
return montant * 0.003; // 0.3%
}
double _calculateMoovMoneyFees(double montant) {
if (montant <= 1000) return 0;
if (montant <= 5000) return 30;
if (montant <= 15000) return 75;
if (montant <= 50000) return 150;
return montant * 0.004; // 0.4%
}
double _calculateCardFees(double montant) {
return 100 + (montant * 0.025); // 100 XOF + 2.5%
}
Color _getMethodColor(String colorHex) {
return Color(int.parse(colorHex.replaceFirst('#', '0xFF')));
}
}

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/animations/page_transitions.dart';
import '../../../demo/presentation/pages/animations_demo_page.dart';
import '../../../debug/debug_api_test_page.dart';
// Imports des nouveaux widgets refactorisés
import '../widgets/welcome/welcome_section_widget.dart';
@@ -31,12 +34,30 @@ class DashboardPage extends StatelessWidget {
backgroundColor: AppTheme.primaryColor,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.animation),
onPressed: () {
Navigator.of(context).push(
PageTransitions.morphWithBlur(const AnimationsDemoPage()),
);
},
tooltip: 'Démonstration des animations',
),
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {
// TODO: Implémenter la navigation vers les notifications
},
),
IconButton(
icon: const Icon(Icons.bug_report),
onPressed: () {
Navigator.of(context).push(
PageTransitions.slideFromRight(const DebugApiTestPage()),
);
},
tooltip: 'Debug API',
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () {

View File

@@ -86,17 +86,17 @@ class RecentActivitiesWidget extends StatelessWidget {
),
],
),
child: Column(
child: const Column(
children: [
ActivityItemWidget(
title: 'Paiement Mobile Money reçu',
description: 'Kouassi Yao - 25,000 FCFA via Orange Money',
icon: Icons.phone_android,
color: const Color(0xFFFF9800),
color: Color(0xFFFF9800),
time: 'Il y a 3 min',
isNew: true,
),
const Divider(height: 1),
Divider(height: 1),
ActivityItemWidget(
title: 'Nouveau membre validé',
description: 'Adjoua Marie inscrite depuis Abidjan',
@@ -105,7 +105,7 @@ class RecentActivitiesWidget extends StatelessWidget {
time: 'Il y a 15 min',
isNew: true,
),
const Divider(height: 1),
Divider(height: 1),
ActivityItemWidget(
title: 'Relance automatique envoyée',
description: '12 SMS de rappel cotisations expédiés',
@@ -113,15 +113,15 @@ class RecentActivitiesWidget extends StatelessWidget {
color: AppTheme.infoColor,
time: 'Il y a 1h',
),
const Divider(height: 1),
Divider(height: 1),
ActivityItemWidget(
title: 'Rapport OHADA généré',
description: 'Bilan financier T4 2024 exporté',
icon: Icons.description,
color: const Color(0xFF795548),
color: Color(0xFF795548),
time: 'Il y a 2h',
),
const Divider(height: 1),
Divider(height: 1),
ActivityItemWidget(
title: 'Événement: Forte participation',
description: 'AG Extraordinaire - 89% de présence',
@@ -129,7 +129,7 @@ class RecentActivitiesWidget extends StatelessWidget {
color: AppTheme.successColor,
time: 'Il y a 3h',
),
const Divider(height: 1),
Divider(height: 1),
ActivityItemWidget(
title: 'Alerte: Cotisations en retard',
description: '23 membres avec +30 jours de retard',
@@ -137,7 +137,7 @@ class RecentActivitiesWidget extends StatelessWidget {
color: AppTheme.warningColor,
time: 'Il y a 4h',
),
const Divider(height: 1),
Divider(height: 1),
ActivityItemWidget(
title: 'Synchronisation réussie',
description: 'Données sauvegardées sur le cloud',
@@ -145,12 +145,12 @@ class RecentActivitiesWidget extends StatelessWidget {
color: AppTheme.successColor,
time: 'Il y a 6h',
),
const Divider(height: 1),
Divider(height: 1),
ActivityItemWidget(
title: 'Message diffusé',
description: 'Info COVID-19 envoyée à 1,247 membres',
icon: Icons.campaign,
color: const Color(0xFF9C27B0),
color: Color(0xFF9C27B0),
time: 'Hier 18:30',
),
],

View File

@@ -86,11 +86,11 @@ class ChartsAnalyticsWidget extends StatelessWidget {
),
),
const SizedBox(width: 8),
Expanded(
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Évolution des membres actifs',
style: TextStyle(
fontSize: 16,
@@ -98,8 +98,8 @@ class ChartsAnalyticsWidget extends StatelessWidget {
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
const Text(
SizedBox(height: 2),
Text(
'Croissance sur 5 mois • +24.7% (+247 membres)',
style: TextStyle(
fontSize: 11,

View File

@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import '../../core/services/api_service.dart';
import '../../core/di/injection.dart';
import '../../shared/theme/app_theme.dart';
/// Page de test pour diagnostiquer les problèmes d'API
class DebugApiTestPage extends StatefulWidget {
const DebugApiTestPage({super.key});
@override
State<DebugApiTestPage> createState() => _DebugApiTestPageState();
}
class _DebugApiTestPageState extends State<DebugApiTestPage> {
final ApiService _apiService = getIt<ApiService>();
String _result = 'Aucun test effectué';
bool _isLoading = false;
Future<void> _testEvenementsAPI() async {
setState(() {
_isLoading = true;
_result = 'Test en cours...';
});
try {
print('🧪 Début du test API événements');
final evenements = await _apiService.getEvenementsAVenir();
setState(() {
_result = '''✅ SUCCÈS !
Nombre d'événements récupérés: ${evenements.length}
Détails des événements:
${evenements.map((e) => '${e.titre} (${e.typeEvenement})').join('\n')}
''';
_isLoading = false;
});
print('🎉 Test réussi: ${evenements.length} événements');
} catch (e) {
setState(() {
_result = '''❌ ERREUR !
Type d'erreur: ${e.runtimeType}
Message: $e
Vérifiez:
1. Le serveur backend est-il démarré ?
2. L'URL est-elle correcte ?
3. Le réseau est-il accessible ?
''';
_isLoading = false;
});
print('💥 Test échoué: $e');
}
}
Future<void> _testConnectivity() async {
setState(() {
_isLoading = true;
_result = 'Test de connectivité...';
});
try {
// Test simple de connectivité via l'API service
final evenements = await _apiService.getEvenementsAVenir(size: 1);
setState(() {
_result = '''✅ CONNECTIVITÉ OK !
Connexion au serveur réussie.
Nombre d'événements de test: ${evenements.length}
''';
_isLoading = false;
});
} catch (e) {
setState(() {
_result = '''❌ PROBLÈME DE CONNECTIVITÉ !
Erreur: $e
Le serveur backend n'est pas accessible.
Vérifiez que le serveur Quarkus est démarré sur 192.168.1.145:8080
''';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Debug API Test'),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Tests de Diagnostic',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _isLoading ? null : _testConnectivity,
icon: const Icon(Icons.network_check),
label: const Text('Test Connectivité'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: _isLoading ? null : _testEvenementsAPI,
icon: const Icon(Icons.event),
label: const Text('Test API Événements'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.successColor,
foregroundColor: Colors.white,
),
),
],
),
),
),
const SizedBox(height: 16),
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Résultats',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (_isLoading)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const SizedBox(height: 16),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: SingleChildScrollView(
child: Text(
_result,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
),
),
],
),
),
),
),
const SizedBox(height: 16),
Card(
color: Colors.blue[50],
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info, color: Colors.blue[700]),
const SizedBox(width: 8),
Text(
'Informations de Configuration',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue[700],
),
),
],
),
const SizedBox(height: 12),
const Text(
'URL Backend: http://192.168.1.145:8080\n'
'Endpoint: /api/evenements/a-venir-public\n'
'Méthode: GET',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,464 @@
import 'package:flutter/material.dart';
import '../../../../core/animations/animated_button.dart';
import '../../../../core/animations/animated_notifications.dart';
import '../../../../core/animations/page_transitions.dart';
import '../../../../shared/theme/app_theme.dart';
/// Page de démonstration des animations
class AnimationsDemoPage extends StatefulWidget {
const AnimationsDemoPage({super.key});
@override
State<AnimationsDemoPage> createState() => _AnimationsDemoPageState();
}
class _AnimationsDemoPageState extends State<AnimationsDemoPage>
with TickerProviderStateMixin {
late AnimationController _floatingController;
late AnimationController _pulseController;
late Animation<double> _floatingAnimation;
late Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_floatingController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
_floatingAnimation = Tween<double>(
begin: -10.0,
end: 10.0,
).animate(CurvedAnimation(
parent: _floatingController,
curve: Curves.easeInOut,
));
_pulseAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.elasticOut,
));
}
@override
void dispose() {
_floatingController.dispose();
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Démonstration des Animations'),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Section Boutons Animés
_buildSection(
'Boutons Animés',
[
const SizedBox(height: 16),
AnimatedButton(
text: 'Bouton Principal',
onPressed: () => _showNotification(NotificationType.success),
style: AnimatedButtonStyle.primary,
),
const SizedBox(height: 12),
AnimatedButton(
text: 'Bouton Secondaire',
onPressed: () => _showNotification(NotificationType.info),
style: AnimatedButtonStyle.secondary,
),
const SizedBox(height: 12),
AnimatedButton(
text: 'Bouton de Succès',
onPressed: () => _showNotification(NotificationType.success),
style: AnimatedButtonStyle.success,
),
const SizedBox(height: 12),
AnimatedButton(
text: 'Bouton d\'Avertissement',
onPressed: () => _showNotification(NotificationType.warning),
style: AnimatedButtonStyle.warning,
),
const SizedBox(height: 12),
AnimatedButton(
text: 'Bouton d\'Erreur',
onPressed: () => _showNotification(NotificationType.error),
style: AnimatedButtonStyle.error,
),
const SizedBox(height: 12),
AnimatedButton(
text: 'Bouton Contour',
onPressed: () => _showNotification(NotificationType.info),
style: AnimatedButtonStyle.outline,
),
],
),
const SizedBox(height: 32),
// Section Notifications
_buildSection(
'Notifications Animées',
[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showNotification(NotificationType.success),
icon: const Icon(Icons.check_circle),
label: const Text('Succès'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.successColor,
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showNotification(NotificationType.error),
icon: const Icon(Icons.error),
label: const Text('Erreur'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showNotification(NotificationType.warning),
icon: const Icon(Icons.warning),
label: const Text('Avertissement'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.warningColor,
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showNotification(NotificationType.info),
icon: const Icon(Icons.info),
label: const Text('Information'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
const SizedBox(height: 32),
// Section Transitions de Page
_buildSection(
'Transitions de Page',
[
const SizedBox(height: 16),
_buildTransitionButton(
'Glissement depuis la droite',
() => _navigateWithTransition(PageTransitions.slideFromRight),
),
const SizedBox(height: 8),
_buildTransitionButton(
'Glissement depuis le bas',
() => _navigateWithTransition(PageTransitions.slideFromBottom),
),
const SizedBox(height: 8),
_buildTransitionButton(
'Fondu',
() => _navigateWithTransition(PageTransitions.fadeIn),
),
const SizedBox(height: 8),
_buildTransitionButton(
'Échelle avec fondu',
() => _navigateWithTransition(PageTransitions.scaleWithFade),
),
const SizedBox(height: 8),
_buildTransitionButton(
'Rebond',
() => _navigateWithTransition(PageTransitions.bounceIn),
),
const SizedBox(height: 8),
_buildTransitionButton(
'Parallaxe',
() => _navigateWithTransition(PageTransitions.slideWithParallax),
),
const SizedBox(height: 8),
_buildTransitionButton(
'Morphing avec Blur',
() => _navigateWithTransition(PageTransitions.morphWithBlur),
),
const SizedBox(height: 8),
_buildTransitionButton(
'Rotation 3D',
() => _navigateWithTransition(PageTransitions.rotate3D),
),
],
),
const SizedBox(height: 32),
// Section Animations Continues
_buildSection(
'Animations Continues',
[
const SizedBox(height: 16),
Center(
child: Column(
children: [
AnimatedBuilder(
animation: _floatingAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _floatingAnimation.value),
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppTheme.primaryColor,
AppTheme.primaryColor.withOpacity(0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.star,
color: Colors.white,
size: 40,
),
),
);
},
),
const SizedBox(height: 16),
const Text(
'Animation Flottante',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 32),
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return Transform.scale(
scale: _pulseAnimation.value,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: AppTheme.successColor,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: AppTheme.successColor.withOpacity(0.4),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: const Icon(
Icons.favorite,
color: Colors.white,
size: 30,
),
),
);
},
),
const SizedBox(height: 16),
const Text(
'Animation Pulsante',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 32),
],
),
),
);
}
Widget _buildSection(String title, List<Widget> children) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const Divider(height: 24),
...children,
],
),
),
);
}
Widget _buildTransitionButton(String text, VoidCallback onPressed) {
return SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: const BorderSide(color: AppTheme.primaryColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
text,
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
),
);
}
void _showNotification(NotificationType type) {
switch (type) {
case NotificationType.success:
AnimatedNotifications.showSuccess(
context,
'Opération réussie avec succès !',
);
break;
case NotificationType.error:
AnimatedNotifications.showError(
context,
'Une erreur s\'est produite lors de l\'opération.',
);
break;
case NotificationType.warning:
AnimatedNotifications.showWarning(
context,
'Attention : cette action nécessite une confirmation.',
);
break;
case NotificationType.info:
AnimatedNotifications.showInfo(
context,
'Information : les données ont été mises à jour.',
);
break;
}
}
void _navigateWithTransition(PageRouteBuilder Function(Widget) transitionBuilder) {
Navigator.of(context).push(
transitionBuilder(const _DemoDestinationPage()),
);
}
}
/// Page de destination pour les démonstrations de transition
class _DemoDestinationPage extends StatelessWidget {
const _DemoDestinationPage();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page de Destination'),
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle,
size: 80,
color: AppTheme.successColor,
),
SizedBox(height: 24),
Text(
'Transition réussie !',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
SizedBox(height: 16),
Text(
'Vous pouvez revenir en arrière\npour tester d\'autres transitions.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
),
);
}
}

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/evenement_model.dart';
import '../../../../core/animations/loading_animations.dart';
import '../../../../core/animations/page_transitions.dart';
import '../../../../shared/theme/app_theme.dart';
import '../bloc/evenement_bloc.dart';
import '../bloc/evenement_event.dart';
@@ -9,6 +11,7 @@ import '../bloc/evenement_state.dart';
import '../widgets/evenement_card.dart';
import '../widgets/evenement_search_bar.dart';
import '../widgets/evenement_filter_chips.dart';
import '../widgets/animated_evenement_list.dart';
import 'evenement_detail_page.dart';
import 'evenement_create_page.dart';
@@ -36,6 +39,9 @@ class _EvenementsPageContent extends StatefulWidget {
class _EvenementsPageContentState extends State<_EvenementsPageContent>
with TickerProviderStateMixin {
late TabController _tabController;
late AnimationController _listAnimationController;
late AnimationController _tabAnimationController;
late Animation<double> _tabFadeAnimation;
final ScrollController _scrollController = ScrollController();
String _searchTerm = '';
TypeEvenement? _selectedType;
@@ -44,18 +50,40 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent>
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_listAnimationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_tabAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_tabFadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _tabAnimationController,
curve: Curves.easeInOut,
),
);
_scrollController.addListener(_onScroll);
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
_onTabChanged(_tabController.index);
}
});
// Démarrer les animations d'entrée
_listAnimationController.forward();
_tabAnimationController.forward();
}
@override
void dispose() {
_tabController.dispose();
_listAnimationController.dispose();
_tabAnimationController.dispose();
_scrollController.dispose();
super.dispose();
}
@@ -192,8 +220,8 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent>
void _navigateToDetail(EvenementModel evenement) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EvenementDetailPage(evenement: evenement),
PageTransitions.slideFromRight(
EvenementDetailPage(evenement: evenement),
),
);
}
@@ -214,28 +242,42 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent>
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildEvenementsList(showSearch: false),
_buildEvenementsList(showSearch: false),
_buildEvenementsList(showSearch: true),
],
body: FadeTransition(
opacity: _tabFadeAnimation,
child: TabBarView(
controller: _tabController,
children: [
_buildEvenementsList(showSearch: false),
_buildEvenementsList(showSearch: false),
_buildEvenementsList(showSearch: true),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => const EvenementCreatePage(),
floatingActionButton: AnimatedBuilder(
animation: _listAnimationController,
builder: (context, child) {
return Transform.scale(
scale: 0.8 + (0.2 * _listAnimationController.value),
child: FloatingActionButton.extended(
onPressed: () async {
final result = await Navigator.of(context).push<bool>(
PageTransitions.slideFromBottom(
const EvenementCreatePage(),
),
);
// Si un événement a été créé, recharger la liste
if (result == true && context.mounted) {
context.read<EvenementBloc>().add(const LoadEvenementsAVenir());
}
},
icon: const Icon(Icons.add),
label: const Text('Nouvel événement'),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
),
);
// Si un événement a été créé, recharger la liste
if (result == true && context.mounted) {
context.read<EvenementBloc>().add(const LoadEvenementsAVenir());
}
},
child: const Icon(Icons.add),
),
);
}
@@ -278,7 +320,7 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red),
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(state.message, textAlign: TextAlign.center),
const SizedBox(height: 16),
@@ -333,45 +375,21 @@ class _EvenementsPageContentState extends State<_EvenementsPageContent>
);
}
final evenements = state is EvenementLoaded
final evenements = state is EvenementLoaded
? state.evenements
: state is EvenementLoadingMore
? state.evenements
: state is EvenementError
? state.evenements ?? <EvenementModel>[]
: <EvenementModel>[];
if (evenements.isEmpty) {
return const Center(
child: Text('Aucun événement disponible'),
);
}
return RefreshIndicator(
onRefresh: () async => _onRefresh(),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: evenements.length +
(state is EvenementLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= evenements.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final evenement = evenements[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: EvenementCard(
evenement: evenement,
onTap: () => _navigateToDetail(evenement),
),
);
},
),
final isLoadingMore = state is EvenementLoadingMore;
return AnimatedEvenementList(
evenements: evenements,
isLoading: isLoadingMore,
onEvenementTap: _navigateToDetail,
onRefresh: _onRefresh,
);
},
),

View File

@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/evenement_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Carte d'événement avec animations sophistiquées
class AnimatedEvenementCard extends StatefulWidget {
final EvenementModel evenement;
final VoidCallback? onTap;
final VoidCallback? onFavorite;
final bool showActions;
const AnimatedEvenementCard({
super.key,
required this.evenement,
this.onTap,
this.onFavorite,
this.showActions = true,
});
@override
State<AnimatedEvenementCard> createState() => _AnimatedEvenementCardState();
}
class _AnimatedEvenementCardState extends State<AnimatedEvenementCard>
with TickerProviderStateMixin {
late AnimationController _hoverController;
late AnimationController _tapController;
late AnimationController _favoriteController;
late Animation<double> _scaleAnimation;
late Animation<double> _elevationAnimation;
late Animation<double> _favoriteScaleAnimation;
late Animation<Color?> _favoriteColorAnimation;
bool _isHovered = false;
bool _isFavorite = false;
@override
void initState() {
super.initState();
_hoverController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_tapController = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: this,
);
_favoriteController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.02,
).animate(CurvedAnimation(
parent: _hoverController,
curve: Curves.easeOutCubic,
));
_elevationAnimation = Tween<double>(
begin: 2.0,
end: 8.0,
).animate(CurvedAnimation(
parent: _hoverController,
curve: Curves.easeOutCubic,
));
_favoriteScaleAnimation = Tween<double>(
begin: 1.0,
end: 1.3,
).animate(CurvedAnimation(
parent: _favoriteController,
curve: Curves.elasticOut,
));
_favoriteColorAnimation = ColorTween(
begin: Colors.grey[400],
end: Colors.red,
).animate(CurvedAnimation(
parent: _favoriteController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_hoverController.dispose();
_tapController.dispose();
_favoriteController.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
_tapController.forward();
}
void _onTapUp(TapUpDetails details) {
_tapController.reverse();
}
void _onTapCancel() {
_tapController.reverse();
}
void _onHover(bool isHovered) {
setState(() => _isHovered = isHovered);
if (isHovered) {
_hoverController.forward();
} else {
_hoverController.reverse();
}
}
void _onFavoriteToggle() {
setState(() => _isFavorite = !_isFavorite);
if (_isFavorite) {
_favoriteController.forward();
} else {
_favoriteController.reverse();
}
widget.onFavorite?.call();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('dd/MM/yyyy');
final timeFormat = DateFormat('HH:mm');
return AnimatedBuilder(
animation: Listenable.merge([
_scaleAnimation,
_elevationAnimation,
_favoriteScaleAnimation,
_favoriteColorAnimation,
]),
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: MouseRegion(
onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false),
child: Card(
elevation: _elevationAnimation.value,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: _isHovered
? LinearGradient(
colors: [
Colors.white,
AppTheme.primaryColor.withOpacity(0.02),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null,
),
child: InkWell(
onTap: widget.onTap,
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec type et actions
Row(
children: [
// Icône du type avec animation
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _isHovered
? AppTheme.primaryColor.withOpacity(0.15)
: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.evenement.typeEvenement.icone,
style: const TextStyle(fontSize: 24),
),
),
const SizedBox(width: 12),
// Type et statut
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.evenement.typeEvenement.libelle,
style: theme.textTheme.bodySmall?.copyWith(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
_buildStatusChip(),
],
),
),
// Bouton favori animé
if (widget.showActions)
GestureDetector(
onTap: _onFavoriteToggle,
child: Transform.scale(
scale: _favoriteScaleAnimation.value,
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _favoriteColorAnimation.value,
size: 24,
),
),
),
],
),
const SizedBox(height: 16),
// Titre avec animation de couleur
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: _isHovered
? AppTheme.primaryColor
: theme.textTheme.titleLarge?.color,
) ?? const TextStyle(),
child: Text(widget.evenement.titre),
),
if (widget.evenement.description?.isNotEmpty == true) ...[
const SizedBox(height: 8),
Text(
widget.evenement.description!,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 16),
// Informations de date et lieu avec icônes animées
Row(
children: [
_buildAnimatedInfo(
icon: Icons.calendar_today,
text: dateFormat.format(widget.evenement.dateDebut),
),
const SizedBox(width: 16),
_buildAnimatedInfo(
icon: Icons.access_time,
text: timeFormat.format(widget.evenement.dateDebut),
),
],
),
if (widget.evenement.lieu?.isNotEmpty == true) ...[
const SizedBox(height: 8),
_buildAnimatedInfo(
icon: Icons.location_on,
text: widget.evenement.lieu!,
),
],
],
),
),
),
),
),
),
);
},
);
}
Widget _buildStatusChip() {
Color statusColor;
switch (widget.evenement.statut) {
case StatutEvenement.planifie:
statusColor = Colors.orange;
break;
case StatutEvenement.confirme:
statusColor = Colors.green;
break;
case StatutEvenement.enCours:
statusColor = Colors.blue;
break;
case StatutEvenement.termine:
statusColor = Colors.grey;
break;
case StatutEvenement.annule:
statusColor = Colors.red;
break;
case StatutEvenement.reporte:
statusColor = Colors.purple;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: statusColor.withOpacity(0.3)),
),
child: Text(
widget.evenement.statut.libelle,
style: TextStyle(
color: statusColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildAnimatedInfo({required IconData icon, required String text}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Icon(
icon,
size: 16,
color: _isHovered
? AppTheme.primaryColor
: Colors.grey[600],
),
),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
],
);
}
}

View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import '../../../../core/models/evenement_model.dart';
import '../../../../core/animations/loading_animations.dart';
import 'evenement_card.dart';
import 'animated_evenement_card.dart';
/// Widget animé pour afficher une liste d'événements avec animations d'apparition
class AnimatedEvenementList extends StatefulWidget {
final List<EvenementModel> evenements;
final Function(EvenementModel)? onEvenementTap;
final bool isLoading;
final VoidCallback? onRefresh;
const AnimatedEvenementList({
super.key,
required this.evenements,
this.onEvenementTap,
this.isLoading = false,
this.onRefresh,
});
@override
State<AnimatedEvenementList> createState() => _AnimatedEvenementListState();
}
class _AnimatedEvenementListState extends State<AnimatedEvenementList>
with TickerProviderStateMixin {
late AnimationController _listController;
List<AnimationController> _itemControllers = [];
List<Animation<double>> _itemAnimations = [];
List<Animation<Offset>> _slideAnimations = [];
@override
void initState() {
super.initState();
_initializeAnimations();
}
@override
void didUpdateWidget(AnimatedEvenementList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.evenements.length != oldWidget.evenements.length) {
_updateAnimations();
}
}
@override
void dispose() {
_listController.dispose();
for (final controller in _itemControllers) {
controller.dispose();
}
super.dispose();
}
void _initializeAnimations() {
_listController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_updateAnimations();
_listController.forward();
}
void _updateAnimations() {
// Dispose des anciens controllers s'ils existent
if (_itemControllers.isNotEmpty) {
for (final controller in _itemControllers) {
controller.dispose();
}
}
// Créer de nouveaux controllers pour chaque élément
_itemControllers = List.generate(
widget.evenements.length,
(index) => AnimationController(
duration: Duration(milliseconds: 300 + (index * 100)),
vsync: this,
),
);
// Animations de fade et scale
_itemAnimations = _itemControllers.map((controller) {
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOutCubic,
),
);
}).toList();
// Animations de slide depuis le bas
_slideAnimations = _itemControllers.map((controller) {
return Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOutCubic,
),
);
}).toList();
// Démarrer les animations avec un délai progressif
for (int i = 0; i < _itemControllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 150), () {
if (mounted) {
_itemControllers[i].forward();
}
});
}
}
@override
Widget build(BuildContext context) {
if (widget.isLoading && widget.evenements.isEmpty) {
return _buildLoadingState();
}
if (widget.evenements.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: () async {
widget.onRefresh?.call();
await Future.delayed(const Duration(milliseconds: 500));
},
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: widget.evenements.length + (widget.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= widget.evenements.length) {
return _buildLoadingIndicator();
}
return _buildAnimatedItem(index);
},
),
);
}
Widget _buildAnimatedItem(int index) {
final evenement = widget.evenements[index];
if (index >= _itemAnimations.length) {
// Fallback pour les nouveaux éléments
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: AnimatedEvenementCard(
evenement: evenement,
onTap: () => widget.onEvenementTap?.call(evenement),
),
);
}
return AnimatedBuilder(
animation: _itemAnimations[index],
builder: (context, child) {
return SlideTransition(
position: _slideAnimations[index],
child: FadeTransition(
opacity: _itemAnimations[index],
child: Transform.scale(
scale: 0.8 + (0.2 * _itemAnimations[index].value),
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: AnimatedEvenementCard(
evenement: evenement,
onTap: () => widget.onEvenementTap?.call(evenement),
),
),
),
),
);
},
);
}
Widget _buildLoadingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LoadingAnimations.waves(),
const SizedBox(height: 24),
const Text(
'Chargement des événements...',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event_busy,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 24),
Text(
'Aucun événement trouvé',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Les événements apparaîtront ici',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
Widget _buildLoadingIndicator() {
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: LoadingAnimations.dots(),
),
);
}
}

View File

@@ -199,11 +199,11 @@ class _MembersListPageState extends State<MembersListPage>
children: [
// Titre principal quand l'AppBar est étendu
if (!innerBoxIsScrolled)
Padding(
padding: const EdgeInsets.only(top: 60),
const Padding(
padding: EdgeInsets.only(top: 60),
child: Text(
'Membres',
style: const TextStyle(
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
@@ -473,7 +473,7 @@ class _MembersListPageState extends State<MembersListPage>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.people_outline,
size: 80,
color: AppTheme.textHint,

View File

@@ -172,13 +172,13 @@ class _MembreDetailsPageState extends State<MembreDetailsPage>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: AppTheme.errorColor),
SizedBox(height: 16),
const Icon(Icons.error, size: 64, color: AppTheme.errorColor),
const SizedBox(height: 16),
Text(state.message),
SizedBox(height: 16),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _membresBloc.add(LoadMembreById(widget.membreId)),
child: Text('Réessayer'),
child: const Text('Réessayer'),
),
],
),

View File

@@ -107,13 +107,13 @@ class _MembresDashboardPageState extends State<MembresDashboardPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.error_outline,
size: 64,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
Text(
const Text(
'Erreur de chargement',
style: TextStyle(
fontSize: 18,
@@ -124,7 +124,7 @@ class _MembresDashboardPageState extends State<MembresDashboardPage> {
const SizedBox(height: 8),
Text(
state.message,
style: TextStyle(
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),

View File

@@ -94,7 +94,7 @@ class _DashboardStatCardState extends State<DashboardStatCard>
child: AnimatedContainer(
duration: DesignSystem.animationFast,
curve: DesignSystem.animationCurve,
padding: EdgeInsets.all(DesignSystem.spacingLg),
padding: const EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
@@ -121,12 +121,12 @@ class _DashboardStatCardState extends State<DashboardStatCard>
if (widget.trend != null) _buildShimmer(60, 24, radius: 12),
],
),
SizedBox(height: DesignSystem.spacingMd),
const SizedBox(height: DesignSystem.spacingMd),
_buildShimmer(80, 32),
SizedBox(height: DesignSystem.spacingSm),
const SizedBox(height: DesignSystem.spacingSm),
_buildShimmer(120, 16),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
_buildShimmer(100, 14),
],
],
@@ -153,10 +153,10 @@ class _DashboardStatCardState extends State<DashboardStatCard>
_buildHeader(),
SizedBox(height: DesignSystem.goldenHeight(DesignSystem.spacingLg)),
_buildValue(),
SizedBox(height: DesignSystem.spacingSm),
const SizedBox(height: DesignSystem.spacingSm),
_buildTitle(),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
_buildSubtitle(),
],
],
@@ -202,7 +202,7 @@ class _DashboardStatCardState extends State<DashboardStatCard>
Widget _buildTrendBadge() {
return Container(
padding: EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingXs,
),
@@ -222,7 +222,7 @@ class _DashboardStatCardState extends State<DashboardStatCard>
color: _getTrendColor(),
size: 14,
),
SizedBox(width: DesignSystem.spacing2xs),
const SizedBox(width: DesignSystem.spacing2xs),
Text(
widget.trend!,
style: DesignSystem.labelSmall.copyWith(

View File

@@ -80,15 +80,15 @@ class MembreCotisationsSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(
Icons.account_balance_wallet,
color: AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Résumé des cotisations',
style: TextStyle(
fontSize: 18,
@@ -201,8 +201,8 @@ class MembreCotisationsSection extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(32),
child: const Padding(
padding: EdgeInsets.all(32),
child: Column(
children: [
Icon(
@@ -210,8 +210,8 @@ class MembreCotisationsSection extends StatelessWidget {
size: 48,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
const Text(
SizedBox(height: 16),
Text(
'Aucune cotisation',
style: TextStyle(
fontSize: 16,
@@ -219,8 +219,8 @@ class MembreCotisationsSection extends StatelessWidget {
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
SizedBox(height: 8),
Text(
'Ce membre n\'a pas encore de cotisations enregistrées.',
textAlign: TextAlign.center,
style: TextStyle(
@@ -237,15 +237,15 @@ class MembreCotisationsSection extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(
Icons.list_alt,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Historique des cotisations',
style: TextStyle(
fontSize: 16,

View File

@@ -56,15 +56,15 @@ class MembreStatsSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(
Icons.analytics,
color: AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Vue d\'ensemble',
style: TextStyle(
fontSize: 18,
@@ -226,15 +226,15 @@ class MembreStatsSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(
Icons.pie_chart,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Répartition des paiements',
style: TextStyle(
fontSize: 16,
@@ -280,15 +280,15 @@ class MembreStatsSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(
Icons.bar_chart,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Évolution des montants',
style: TextStyle(
fontSize: 16,
@@ -363,15 +363,15 @@ class MembreStatsSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(
Icons.timeline,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Chronologie',
style: TextStyle(
fontSize: 16,
@@ -474,7 +474,7 @@ class MembreStatsSection extends StatelessWidget {
padding: const EdgeInsets.all(40),
child: Column(
children: [
Icon(
const Icon(
Icons.bar_chart,
size: 48,
color: AppTheme.textHint,

View File

@@ -51,7 +51,7 @@ class _MembresExportDialogState extends State<MembresExportDialog> {
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
child: const Icon(
Icons.file_download,
color: AppTheme.primaryColor,
size: 24,
@@ -116,15 +116,15 @@ class _MembresExportDialogState extends State<MembresExportDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(
Icons.info_outline,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
SizedBox(width: 8),
Text(
'Données à exporter',
style: TextStyle(
fontSize: 16,

View File

@@ -43,7 +43,7 @@ class MembresViewControls extends StatelessWidget {
),
child: Text(
'$totalCount membre${totalCount > 1 ? 's' : ''}',
style: TextStyle(
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.primaryColor,
@@ -72,7 +72,7 @@ class MembresViewControls extends StatelessWidget {
PopupMenuButton<String>(
initialValue: sortBy,
onSelected: onSortChanged,
icon: Icon(
icon: const Icon(
Icons.sort,
size: 20,
color: AppTheme.textSecondary,