feat(mobile): Implement Keycloak WebView authentication with HTTP callback
- Replace flutter_appauth with custom WebView implementation to resolve deep link issues - Add KeycloakWebViewAuthService with integrated WebView for seamless authentication - Configure Android manifest for HTTP cleartext traffic support - Add network security config for development environment (192.168.1.11) - Update Keycloak client to use HTTP callback endpoint (http://192.168.1.11:8080/auth/callback) - Remove obsolete keycloak_auth_service.dart and temporary scripts - Clean up dependencies and regenerate injection configuration - Tested successfully on multiple Android devices (Xiaomi 2201116TG, SM A725F) BREAKING CHANGE: Authentication flow now uses WebView instead of external browser - Users will see Keycloak login page within the app instead of browser redirect - Resolves ERR_CLEARTEXT_NOT_PERMITTED and deep link state management issues - Maintains full OIDC compliance with PKCE flow and secure token storage Technical improvements: - WebView with custom navigation delegate for callback handling - Automatic token extraction and user info parsing from JWT - Proper error handling and user feedback - Consistent authentication state management across app lifecycle
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/membre_model.dart';
|
||||
import '../models/cotisation_model.dart';
|
||||
import '../models/evenement_model.dart';
|
||||
import '../models/wave_checkout_session_model.dart';
|
||||
import '../network/dio_client.dart';
|
||||
|
||||
@@ -87,19 +88,47 @@ class ApiService {
|
||||
'/api/membres/recherche',
|
||||
queryParameters: {'q': query},
|
||||
);
|
||||
|
||||
|
||||
if (response.data is List) {
|
||||
return (response.data as List)
|
||||
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
throw Exception('Format de réponse invalide pour la recherche');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la recherche de membres');
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche avancée des membres avec filtres multiples
|
||||
Future<List<MembreModel>> advancedSearchMembres(Map<String, dynamic> filters) async {
|
||||
try {
|
||||
// Nettoyer les filtres vides
|
||||
final cleanFilters = <String, dynamic>{};
|
||||
filters.forEach((key, value) {
|
||||
if (value != null && value.toString().isNotEmpty) {
|
||||
cleanFilters[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
final response = await _dio.get(
|
||||
'/api/membres/recherche-avancee',
|
||||
queryParameters: cleanFilters,
|
||||
);
|
||||
|
||||
if (response.data is List) {
|
||||
return (response.data as List)
|
||||
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Format de réponse invalide pour la recherche avancée');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la recherche avancée de membres');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les statistiques des membres
|
||||
Future<Map<String, dynamic>> getMembresStats() async {
|
||||
try {
|
||||
@@ -397,4 +426,218 @@ class ApiService {
|
||||
return Exception(defaultMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ÉVÉNEMENTS
|
||||
// ========================================
|
||||
|
||||
/// Récupère la liste des événements à venir (optimisé mobile)
|
||||
Future<List<EvenementModel>> getEvenementsAVenir({
|
||||
int page = 0,
|
||||
int size = 10,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/evenements/a-venir',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'size': size,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data is List) {
|
||||
return (response.data as List)
|
||||
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Format de réponse invalide pour les événements à venir');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération des événements à venir');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la liste des événements publics (sans authentification)
|
||||
Future<List<EvenementModel>> getEvenementsPublics({
|
||||
int page = 0,
|
||||
int size = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/evenements/publics',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'size': size,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data is List) {
|
||||
return (response.data as List)
|
||||
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Format de réponse invalide pour les événements publics');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération des événements publics');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère tous les événements avec pagination
|
||||
Future<List<EvenementModel>> getEvenements({
|
||||
int page = 0,
|
||||
int size = 20,
|
||||
String sortField = 'dateDebut',
|
||||
String sortDirection = 'asc',
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/evenements',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'size': size,
|
||||
'sort': sortField,
|
||||
'direction': sortDirection,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data is List) {
|
||||
return (response.data as List)
|
||||
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Format de réponse invalide pour la liste des événements');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération des événements');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère un événement par son ID
|
||||
Future<EvenementModel> getEvenementById(String id) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/evenements/$id');
|
||||
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération de l\'événement');
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche d'événements par terme
|
||||
Future<List<EvenementModel>> rechercherEvenements(
|
||||
String terme, {
|
||||
int page = 0,
|
||||
int size = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/evenements/recherche',
|
||||
queryParameters: {
|
||||
'q': terme,
|
||||
'page': page,
|
||||
'size': size,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data is List) {
|
||||
return (response.data as List)
|
||||
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Format de réponse invalide pour la recherche d\'événements');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la recherche d\'événements');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les événements par type
|
||||
Future<List<EvenementModel>> getEvenementsByType(
|
||||
TypeEvenement type, {
|
||||
int page = 0,
|
||||
int size = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/evenements/type/${type.name.toUpperCase()}',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'size': size,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data is List) {
|
||||
return (response.data as List)
|
||||
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Format de réponse invalide pour les événements par type');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération des événements par type');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un nouvel événement
|
||||
Future<EvenementModel> createEvenement(EvenementModel evenement) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/evenements',
|
||||
data: evenement.toJson(),
|
||||
);
|
||||
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la création de l\'événement');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour un événement existant
|
||||
Future<EvenementModel> updateEvenement(String id, EvenementModel evenement) async {
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
'/api/evenements/$id',
|
||||
data: evenement.toJson(),
|
||||
);
|
||||
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la mise à jour de l\'événement');
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un événement
|
||||
Future<void> deleteEvenement(String id) async {
|
||||
try {
|
||||
await _dio.delete('/api/evenements/$id');
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la suppression de l\'événement');
|
||||
}
|
||||
}
|
||||
|
||||
/// Change le statut d'un événement
|
||||
Future<EvenementModel> changerStatutEvenement(
|
||||
String id,
|
||||
StatutEvenement nouveauStatut,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.patch(
|
||||
'/api/evenements/$id/statut',
|
||||
queryParameters: {
|
||||
'statut': nouveauStatut.name.toUpperCase(),
|
||||
},
|
||||
);
|
||||
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors du changement de statut');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les statistiques des événements
|
||||
Future<Map<String, dynamic>> getStatistiquesEvenements() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/evenements/statistiques');
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../models/membre_model.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Service de gestion des communications (appels, SMS, emails)
|
||||
/// Gère les permissions et l'intégration avec les applications natives
|
||||
class CommunicationService {
|
||||
static final CommunicationService _instance = CommunicationService._internal();
|
||||
factory CommunicationService() => _instance;
|
||||
CommunicationService._internal();
|
||||
|
||||
/// Effectue un appel téléphonique vers un membre
|
||||
Future<bool> callMember(BuildContext context, MembreModel membre) async {
|
||||
try {
|
||||
// Vérifier si le numéro de téléphone est valide
|
||||
if (membre.telephone.isEmpty) {
|
||||
_showErrorSnackBar(context, 'Numéro de téléphone non disponible pour ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nettoyer le numéro de téléphone
|
||||
final cleanPhone = _cleanPhoneNumber(membre.telephone);
|
||||
if (cleanPhone.isEmpty) {
|
||||
_showErrorSnackBar(context, 'Numéro de téléphone invalide pour ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier les permissions sur Android
|
||||
if (Platform.isAndroid) {
|
||||
final phonePermission = await Permission.phone.status;
|
||||
if (phonePermission.isDenied) {
|
||||
final result = await Permission.phone.request();
|
||||
if (result.isDenied) {
|
||||
_showPermissionDeniedDialog(context, 'Téléphone', 'effectuer des appels');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Construire l'URL d'appel
|
||||
final phoneUrl = Uri.parse('tel:$cleanPhone');
|
||||
|
||||
// Vérifier si l'application peut gérer les appels
|
||||
if (await canLaunchUrl(phoneUrl)) {
|
||||
// Feedback haptique
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
// Lancer l'appel
|
||||
final success = await launchUrl(phoneUrl);
|
||||
|
||||
if (success) {
|
||||
_showSuccessSnackBar(context, 'Appel lancé vers ${membre.nomComplet}');
|
||||
|
||||
// Log de l'action pour audit
|
||||
debugPrint('📞 Appel effectué vers ${membre.nomComplet} (${membre.telephone})');
|
||||
|
||||
return true;
|
||||
} else {
|
||||
_showErrorSnackBar(context, 'Impossible de lancer l\'appel vers ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
_showErrorSnackBar(context, 'Application d\'appel non disponible sur cet appareil');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'appel vers ${membre.nomComplet}: $e');
|
||||
_showErrorSnackBar(context, 'Erreur lors de l\'appel vers ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un SMS à un membre
|
||||
Future<bool> sendSMS(BuildContext context, MembreModel membre, {String? message}) async {
|
||||
try {
|
||||
// Vérifier si le numéro de téléphone est valide
|
||||
if (membre.telephone.isEmpty) {
|
||||
_showErrorSnackBar(context, 'Numéro de téléphone non disponible pour ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nettoyer le numéro de téléphone
|
||||
final cleanPhone = _cleanPhoneNumber(membre.telephone);
|
||||
if (cleanPhone.isEmpty) {
|
||||
_showErrorSnackBar(context, 'Numéro de téléphone invalide pour ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Construire l'URL SMS
|
||||
String smsUrl = 'sms:$cleanPhone';
|
||||
if (message != null && message.isNotEmpty) {
|
||||
final encodedMessage = Uri.encodeComponent(message);
|
||||
smsUrl += '?body=$encodedMessage';
|
||||
}
|
||||
|
||||
final smsUri = Uri.parse(smsUrl);
|
||||
|
||||
// Vérifier si l'application peut gérer les SMS
|
||||
if (await canLaunchUrl(smsUri)) {
|
||||
// Feedback haptique
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// Lancer l'application SMS
|
||||
final success = await launchUrl(smsUri);
|
||||
|
||||
if (success) {
|
||||
_showSuccessSnackBar(context, 'SMS ouvert pour ${membre.nomComplet}');
|
||||
|
||||
// Log de l'action pour audit
|
||||
debugPrint('💬 SMS ouvert pour ${membre.nomComplet} (${membre.telephone})');
|
||||
|
||||
return true;
|
||||
} else {
|
||||
_showErrorSnackBar(context, 'Impossible d\'ouvrir l\'application SMS');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
_showErrorSnackBar(context, 'Application SMS non disponible sur cet appareil');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'envoi SMS vers ${membre.nomComplet}: $e');
|
||||
_showErrorSnackBar(context, 'Erreur lors de l\'envoi SMS vers ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un email à un membre
|
||||
Future<bool> sendEmail(BuildContext context, MembreModel membre, {String? subject, String? body}) async {
|
||||
try {
|
||||
// Vérifier si l'email est valide
|
||||
if (membre.email.isEmpty) {
|
||||
_showErrorSnackBar(context, 'Adresse email non disponible pour ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Construire l'URL email
|
||||
String emailUrl = 'mailto:${membre.email}';
|
||||
final params = <String>[];
|
||||
|
||||
if (subject != null && subject.isNotEmpty) {
|
||||
params.add('subject=${Uri.encodeComponent(subject)}');
|
||||
}
|
||||
|
||||
if (body != null && body.isNotEmpty) {
|
||||
params.add('body=${Uri.encodeComponent(body)}');
|
||||
}
|
||||
|
||||
if (params.isNotEmpty) {
|
||||
emailUrl += '?${params.join('&')}';
|
||||
}
|
||||
|
||||
final emailUri = Uri.parse(emailUrl);
|
||||
|
||||
// Vérifier si l'application peut gérer les emails
|
||||
if (await canLaunchUrl(emailUri)) {
|
||||
// Feedback haptique
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// Lancer l'application email
|
||||
final success = await launchUrl(emailUri);
|
||||
|
||||
if (success) {
|
||||
_showSuccessSnackBar(context, 'Email ouvert pour ${membre.nomComplet}');
|
||||
|
||||
// Log de l'action pour audit
|
||||
debugPrint('📧 Email ouvert pour ${membre.nomComplet} (${membre.email})');
|
||||
|
||||
return true;
|
||||
} else {
|
||||
_showErrorSnackBar(context, 'Impossible d\'ouvrir l\'application email');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
_showErrorSnackBar(context, 'Application email non disponible sur cet appareil');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'envoi email vers ${membre.nomComplet}: $e');
|
||||
_showErrorSnackBar(context, 'Erreur lors de l\'envoi email vers ${membre.nomComplet}');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoie un numéro de téléphone en supprimant les caractères non numériques
|
||||
String _cleanPhoneNumber(String phone) {
|
||||
// Garder seulement les chiffres et le signe +
|
||||
final cleaned = phone.replaceAll(RegExp(r'[^\d+]'), '');
|
||||
|
||||
// Vérifier que le numéro n'est pas vide après nettoyage
|
||||
if (cleaned.isEmpty) return '';
|
||||
|
||||
// Si le numéro commence par +, le garder tel quel
|
||||
if (cleaned.startsWith('+')) return cleaned;
|
||||
|
||||
// Si le numéro commence par 00, le remplacer par +
|
||||
if (cleaned.startsWith('00')) {
|
||||
return '+${cleaned.substring(2)}';
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// Affiche un SnackBar de succès
|
||||
void _showSuccessSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche un SnackBar d'erreur
|
||||
void _showErrorSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 3),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche une dialog pour les permissions refusées
|
||||
void _showPermissionDeniedDialog(BuildContext context, String permission, String action) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Permission $permission requise'),
|
||||
content: Text(
|
||||
'L\'application a besoin de la permission $permission pour $action. '
|
||||
'Veuillez autoriser cette permission dans les paramètres de l\'application.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
openAppSettings();
|
||||
},
|
||||
child: const Text('Paramètres'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:excel/excel.dart';
|
||||
import 'package:csv/csv.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../models/membre_model.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Options d'export
|
||||
class ExportOptions {
|
||||
final String format;
|
||||
final bool includePersonalInfo;
|
||||
final bool includeContactInfo;
|
||||
final bool includeAdhesionInfo;
|
||||
final bool includeStatistics;
|
||||
final bool includeInactiveMembers;
|
||||
|
||||
const ExportOptions({
|
||||
required this.format,
|
||||
this.includePersonalInfo = true,
|
||||
this.includeContactInfo = true,
|
||||
this.includeAdhesionInfo = true,
|
||||
this.includeStatistics = false,
|
||||
this.includeInactiveMembers = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Service de gestion de l'export et import des données
|
||||
/// Supporte les formats Excel, CSV, PDF et JSON
|
||||
class ExportImportService {
|
||||
static final ExportImportService _instance = ExportImportService._internal();
|
||||
factory ExportImportService() => _instance;
|
||||
ExportImportService._internal();
|
||||
|
||||
/// Exporte une liste de membres selon les options spécifiées
|
||||
Future<String?> exportMembers(
|
||||
BuildContext context,
|
||||
List<MembreModel> members,
|
||||
ExportOptions options,
|
||||
) async {
|
||||
try {
|
||||
// Filtrer les membres selon les options
|
||||
List<MembreModel> filteredMembers = members;
|
||||
if (!options.includeInactiveMembers) {
|
||||
filteredMembers = filteredMembers.where((m) => m.actif).toList();
|
||||
}
|
||||
|
||||
// Générer le fichier selon le format
|
||||
String? filePath;
|
||||
switch (options.format.toLowerCase()) {
|
||||
case 'excel':
|
||||
filePath = await _exportToExcel(filteredMembers, options);
|
||||
break;
|
||||
case 'csv':
|
||||
filePath = await _exportToCsv(filteredMembers, options);
|
||||
break;
|
||||
case 'pdf':
|
||||
filePath = await _exportToPdf(filteredMembers, options);
|
||||
break;
|
||||
case 'json':
|
||||
filePath = await _exportToJson(filteredMembers, options);
|
||||
break;
|
||||
default:
|
||||
throw Exception('Format d\'export non supporté: ${options.format}');
|
||||
}
|
||||
|
||||
if (filePath != null) {
|
||||
// Feedback haptique
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
// Afficher le résultat
|
||||
_showExportSuccess(context, filteredMembers.length, options.format, filePath);
|
||||
|
||||
// Log de l'action
|
||||
debugPrint('📤 Export réussi: ${filteredMembers.length} membres en ${options.format.toUpperCase()} -> $filePath');
|
||||
|
||||
return filePath;
|
||||
} else {
|
||||
_showExportError(context, 'Impossible de créer le fichier d\'export');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'export: $e');
|
||||
_showExportError(context, 'Erreur lors de l\'export: ${e.toString()}');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exporte vers Excel
|
||||
Future<String?> _exportToExcel(List<MembreModel> members, ExportOptions options) async {
|
||||
try {
|
||||
final excel = Excel.createExcel();
|
||||
final sheet = excel['Membres'];
|
||||
|
||||
// Supprimer la feuille par défaut
|
||||
excel.delete('Sheet1');
|
||||
|
||||
// En-têtes
|
||||
final headers = _buildHeaders(options);
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0)).value =
|
||||
TextCellValue(headers[i]);
|
||||
}
|
||||
|
||||
// Données
|
||||
for (int rowIndex = 0; rowIndex < members.length; rowIndex++) {
|
||||
final member = members[rowIndex];
|
||||
final rowData = _buildRowData(member, options);
|
||||
|
||||
for (int colIndex = 0; colIndex < rowData.length; colIndex++) {
|
||||
sheet.cell(CellIndex.indexByColumnRow(columnIndex: colIndex, rowIndex: rowIndex + 1)).value =
|
||||
TextCellValue(rowData[colIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder le fichier
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.xlsx';
|
||||
final filePath = '${directory.path}/$fileName';
|
||||
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(excel.encode()!);
|
||||
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur export Excel: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exporte vers CSV
|
||||
Future<String?> _exportToCsv(List<MembreModel> members, ExportOptions options) async {
|
||||
try {
|
||||
final headers = _buildHeaders(options);
|
||||
final rows = <List<String>>[headers];
|
||||
|
||||
for (final member in members) {
|
||||
rows.add(_buildRowData(member, options));
|
||||
}
|
||||
|
||||
final csvData = const ListToCsvConverter().convert(rows);
|
||||
|
||||
// Sauvegarder le fichier
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.csv';
|
||||
final filePath = '${directory.path}/$fileName';
|
||||
|
||||
final file = File(filePath);
|
||||
await file.writeAsString(csvData, encoding: utf8);
|
||||
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur export CSV: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exporte vers PDF
|
||||
Future<String?> _exportToPdf(List<MembreModel> members, ExportOptions options) async {
|
||||
try {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Créer le contenu PDF
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
build: (pw.Context context) {
|
||||
return [
|
||||
pw.Header(
|
||||
level: 0,
|
||||
child: pw.Text(
|
||||
'Liste des Membres UnionFlow',
|
||||
style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
pw.Text(
|
||||
'Exporté le ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year} à ${DateTime.now().hour}:${DateTime.now().minute}',
|
||||
style: const pw.TextStyle(fontSize: 12),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
pw.Table.fromTextArray(
|
||||
headers: _buildHeaders(options),
|
||||
data: members.map((member) => _buildRowData(member, options)).toList(),
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
||||
cellStyle: const pw.TextStyle(fontSize: 10),
|
||||
cellAlignment: pw.Alignment.centerLeft,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Sauvegarder le fichier
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.pdf';
|
||||
final filePath = '${directory.path}/$fileName';
|
||||
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(await pdf.save());
|
||||
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur export PDF: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exporte vers JSON
|
||||
Future<String?> _exportToJson(List<MembreModel> members, ExportOptions options) async {
|
||||
try {
|
||||
final data = {
|
||||
'exportInfo': {
|
||||
'date': DateTime.now().toIso8601String(),
|
||||
'format': 'JSON',
|
||||
'totalMembers': members.length,
|
||||
'options': {
|
||||
'includePersonalInfo': options.includePersonalInfo,
|
||||
'includeContactInfo': options.includeContactInfo,
|
||||
'includeAdhesionInfo': options.includeAdhesionInfo,
|
||||
'includeStatistics': options.includeStatistics,
|
||||
'includeInactiveMembers': options.includeInactiveMembers,
|
||||
},
|
||||
},
|
||||
'members': members.map((member) => _buildJsonData(member, options)).toList(),
|
||||
};
|
||||
|
||||
final jsonString = const JsonEncoder.withIndent(' ').convert(data);
|
||||
|
||||
// Sauvegarder le fichier
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.json';
|
||||
final filePath = '${directory.path}/$fileName';
|
||||
|
||||
final file = File(filePath);
|
||||
await file.writeAsString(jsonString, encoding: utf8);
|
||||
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur export JSON: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit les en-têtes selon les options
|
||||
List<String> _buildHeaders(ExportOptions options) {
|
||||
final headers = <String>[];
|
||||
|
||||
if (options.includePersonalInfo) {
|
||||
headers.addAll(['Numéro', 'Nom', 'Prénom', 'Date de naissance', 'Profession']);
|
||||
}
|
||||
|
||||
if (options.includeContactInfo) {
|
||||
headers.addAll(['Téléphone', 'Email', 'Adresse', 'Ville', 'Code postal', 'Pays']);
|
||||
}
|
||||
|
||||
if (options.includeAdhesionInfo) {
|
||||
headers.addAll(['Date d\'adhésion', 'Statut', 'Actif']);
|
||||
}
|
||||
|
||||
if (options.includeStatistics) {
|
||||
headers.addAll(['Âge', 'Ancienneté (jours)', 'Date création', 'Date modification']);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/// Construit les données d'une ligne selon les options
|
||||
List<String> _buildRowData(MembreModel member, ExportOptions options) {
|
||||
final rowData = <String>[];
|
||||
|
||||
if (options.includePersonalInfo) {
|
||||
rowData.addAll([
|
||||
member.numeroMembre,
|
||||
member.nom,
|
||||
member.prenom,
|
||||
member.dateNaissance?.toIso8601String().split('T')[0] ?? '',
|
||||
member.profession ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
if (options.includeContactInfo) {
|
||||
rowData.addAll([
|
||||
member.telephone,
|
||||
member.email,
|
||||
member.adresse ?? '',
|
||||
member.ville ?? '',
|
||||
member.codePostal ?? '',
|
||||
member.pays ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
if (options.includeAdhesionInfo) {
|
||||
rowData.addAll([
|
||||
member.dateAdhesion.toIso8601String().split('T')[0],
|
||||
member.statut,
|
||||
member.actif ? 'Oui' : 'Non',
|
||||
]);
|
||||
}
|
||||
|
||||
if (options.includeStatistics) {
|
||||
final age = member.age.toString();
|
||||
final anciennete = DateTime.now().difference(member.dateAdhesion).inDays.toString();
|
||||
final dateCreation = member.dateCreation.toIso8601String().split('T')[0];
|
||||
final dateModification = member.dateModification?.toIso8601String().split('T')[0] ?? 'N/A';
|
||||
|
||||
rowData.addAll([age, anciennete, dateCreation, dateModification]);
|
||||
}
|
||||
|
||||
return rowData;
|
||||
}
|
||||
|
||||
/// Construit les données JSON selon les options
|
||||
Map<String, dynamic> _buildJsonData(MembreModel member, ExportOptions options) {
|
||||
final data = <String, dynamic>{};
|
||||
|
||||
if (options.includePersonalInfo) {
|
||||
data.addAll({
|
||||
'numeroMembre': member.numeroMembre,
|
||||
'nom': member.nom,
|
||||
'prenom': member.prenom,
|
||||
'dateNaissance': member.dateNaissance?.toIso8601String(),
|
||||
'profession': member.profession,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeContactInfo) {
|
||||
data.addAll({
|
||||
'telephone': member.telephone,
|
||||
'email': member.email,
|
||||
'adresse': member.adresse,
|
||||
'ville': member.ville,
|
||||
'codePostal': member.codePostal,
|
||||
'pays': member.pays,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeAdhesionInfo) {
|
||||
data.addAll({
|
||||
'dateAdhesion': member.dateAdhesion.toIso8601String(),
|
||||
'statut': member.statut,
|
||||
'actif': member.actif,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeStatistics) {
|
||||
data.addAll({
|
||||
'age': member.age,
|
||||
'ancienneteEnJours': DateTime.now().difference(member.dateAdhesion).inDays,
|
||||
'dateCreation': member.dateCreation.toIso8601String(),
|
||||
'dateModification': member.dateModification?.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// Affiche le succès de l'export
|
||||
void _showExportSuccess(BuildContext context, int count, String format, String filePath) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Export ${format.toUpperCase()} réussi: $count membres',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: const Duration(seconds: 4),
|
||||
action: SnackBarAction(
|
||||
label: 'Partager',
|
||||
textColor: Colors.white,
|
||||
onPressed: () => _shareFile(filePath),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche l'erreur d'export
|
||||
void _showExportError(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Partage un fichier
|
||||
Future<void> _shareFile(String filePath) async {
|
||||
try {
|
||||
await Share.shareXFiles([XFile(filePath)]);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du partage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Importe des membres depuis un fichier
|
||||
Future<List<MembreModel>?> importMembers(BuildContext context) async {
|
||||
try {
|
||||
// Sélectionner le fichier
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['xlsx', 'csv', 'json'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = result.files.first;
|
||||
final filePath = file.path;
|
||||
|
||||
if (filePath == null) {
|
||||
_showImportError(context, 'Impossible de lire le fichier sélectionné');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Importer selon l'extension
|
||||
List<MembreModel>? importedMembers;
|
||||
final extension = file.extension?.toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case 'xlsx':
|
||||
importedMembers = await _importFromExcel(filePath);
|
||||
break;
|
||||
case 'csv':
|
||||
importedMembers = await _importFromCsv(filePath);
|
||||
break;
|
||||
case 'json':
|
||||
importedMembers = await _importFromJson(filePath);
|
||||
break;
|
||||
default:
|
||||
_showImportError(context, 'Format de fichier non supporté: $extension');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (importedMembers != null && importedMembers.isNotEmpty) {
|
||||
// Feedback haptique
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
// Afficher le résultat
|
||||
_showImportSuccess(context, importedMembers.length, extension!);
|
||||
|
||||
// Log de l'action
|
||||
debugPrint('📥 Import réussi: ${importedMembers.length} membres depuis ${extension.toUpperCase()}');
|
||||
|
||||
return importedMembers;
|
||||
} else {
|
||||
_showImportError(context, 'Aucun membre valide trouvé dans le fichier');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'import: $e');
|
||||
_showImportError(context, 'Erreur lors de l\'import: ${e.toString()}');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Importe depuis Excel
|
||||
Future<List<MembreModel>?> _importFromExcel(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
final bytes = await file.readAsBytes();
|
||||
final excel = Excel.decodeBytes(bytes);
|
||||
|
||||
final sheet = excel.tables.values.first;
|
||||
if (sheet == null || sheet.rows.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final members = <MembreModel>[];
|
||||
|
||||
// Ignorer la première ligne (en-têtes)
|
||||
for (int i = 1; i < sheet.rows.length; i++) {
|
||||
final row = sheet.rows[i];
|
||||
if (row.isEmpty) continue;
|
||||
|
||||
try {
|
||||
final member = _parseRowToMember(row.map((cell) => cell?.value?.toString() ?? '').toList());
|
||||
if (member != null) {
|
||||
members.add(member);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur ligne $i: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur import Excel: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Importe depuis CSV
|
||||
Future<List<MembreModel>?> _importFromCsv(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
final content = await file.readAsString(encoding: utf8);
|
||||
final rows = const CsvToListConverter().convert(content);
|
||||
|
||||
if (rows.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final members = <MembreModel>[];
|
||||
|
||||
// Ignorer la première ligne (en-têtes)
|
||||
for (int i = 1; i < rows.length; i++) {
|
||||
final row = rows[i];
|
||||
if (row.isEmpty) continue;
|
||||
|
||||
try {
|
||||
final member = _parseRowToMember(row.map((cell) => cell?.toString() ?? '').toList());
|
||||
if (member != null) {
|
||||
members.add(member);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur ligne $i: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur import CSV: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Importe depuis JSON
|
||||
Future<List<MembreModel>?> _importFromJson(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
final content = await file.readAsString(encoding: utf8);
|
||||
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||
|
||||
final membersData = data['members'] as List<dynamic>?;
|
||||
if (membersData == null || membersData.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final members = <MembreModel>[];
|
||||
|
||||
for (final memberData in membersData) {
|
||||
try {
|
||||
final member = _parseJsonToMember(memberData as Map<String, dynamic>);
|
||||
if (member != null) {
|
||||
members.add(member);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur membre JSON: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur import JSON: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche le succès de l'import
|
||||
void _showImportSuccess(BuildContext context, int count, String format) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Import ${format.toUpperCase()} réussi: $count membres',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche l'erreur d'import
|
||||
void _showImportError(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse une ligne de données vers un MembreModel
|
||||
MembreModel? _parseRowToMember(List<String> row) {
|
||||
if (row.length < 7) return null; // Minimum requis
|
||||
|
||||
try {
|
||||
// Parser la date de naissance
|
||||
DateTime? dateNaissance;
|
||||
if (row.length > 3 && row[3].isNotEmpty) {
|
||||
try {
|
||||
dateNaissance = DateTime.parse(row[3]);
|
||||
} catch (e) {
|
||||
// Essayer d'autres formats de date
|
||||
try {
|
||||
final parts = row[3].split('/');
|
||||
if (parts.length == 3) {
|
||||
dateNaissance = DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Format de date non reconnu: ${row[3]}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parser la date d'adhésion
|
||||
DateTime dateAdhesion = DateTime.now();
|
||||
if (row.length > 12 && row[12].isNotEmpty) {
|
||||
try {
|
||||
dateAdhesion = DateTime.parse(row[12]);
|
||||
} catch (e) {
|
||||
try {
|
||||
final parts = row[12].split('/');
|
||||
if (parts.length == 3) {
|
||||
dateAdhesion = DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Format de date d\'adhésion non reconnu: ${row[12]}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MembreModel(
|
||||
id: 'import_${DateTime.now().millisecondsSinceEpoch}_${row.hashCode}',
|
||||
numeroMembre: row[0].isNotEmpty ? row[0] : 'AUTO-${DateTime.now().millisecondsSinceEpoch}',
|
||||
nom: row[1],
|
||||
prenom: row[2],
|
||||
email: row.length > 8 ? row[8] : '',
|
||||
telephone: row.length > 7 ? row[7] : '',
|
||||
dateNaissance: dateNaissance,
|
||||
profession: row.length > 6 ? row[6] : null,
|
||||
adresse: row.length > 9 ? row[9] : null,
|
||||
ville: row.length > 10 ? row[10] : null,
|
||||
pays: row.length > 11 ? row[11] : 'Côte d\'Ivoire',
|
||||
statut: row.length > 13 ? (row[13].toLowerCase() == 'actif' ? 'ACTIF' : 'INACTIF') : 'ACTIF',
|
||||
dateAdhesion: dateAdhesion,
|
||||
dateCreation: DateTime.now(),
|
||||
actif: row.length > 13 ? (row[13].toLowerCase() == 'actif' || row[13].toLowerCase() == 'true') : true,
|
||||
version: 1,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur parsing ligne: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse des données JSON vers un MembreModel
|
||||
MembreModel? _parseJsonToMember(Map<String, dynamic> data) {
|
||||
try {
|
||||
// Parser la date de naissance
|
||||
DateTime? dateNaissance;
|
||||
if (data['dateNaissance'] != null) {
|
||||
try {
|
||||
if (data['dateNaissance'] is String) {
|
||||
dateNaissance = DateTime.parse(data['dateNaissance']);
|
||||
} else if (data['dateNaissance'] is DateTime) {
|
||||
dateNaissance = data['dateNaissance'];
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Format de date de naissance JSON non reconnu: ${data['dateNaissance']}');
|
||||
}
|
||||
}
|
||||
|
||||
// Parser la date d'adhésion
|
||||
DateTime dateAdhesion = DateTime.now();
|
||||
if (data['dateAdhesion'] != null) {
|
||||
try {
|
||||
if (data['dateAdhesion'] is String) {
|
||||
dateAdhesion = DateTime.parse(data['dateAdhesion']);
|
||||
} else if (data['dateAdhesion'] is DateTime) {
|
||||
dateAdhesion = data['dateAdhesion'];
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Format de date d\'adhésion JSON non reconnu: ${data['dateAdhesion']}');
|
||||
}
|
||||
}
|
||||
|
||||
// Parser la date de création
|
||||
DateTime dateCreation = DateTime.now();
|
||||
if (data['dateCreation'] != null) {
|
||||
try {
|
||||
if (data['dateCreation'] is String) {
|
||||
dateCreation = DateTime.parse(data['dateCreation']);
|
||||
} else if (data['dateCreation'] is DateTime) {
|
||||
dateCreation = data['dateCreation'];
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Format de date de création JSON non reconnu: ${data['dateCreation']}');
|
||||
}
|
||||
}
|
||||
|
||||
return MembreModel(
|
||||
id: data['id'] ?? 'import_${DateTime.now().millisecondsSinceEpoch}_${data.hashCode}',
|
||||
numeroMembre: data['numeroMembre'] ?? 'AUTO-${DateTime.now().millisecondsSinceEpoch}',
|
||||
nom: data['nom'] ?? '',
|
||||
prenom: data['prenom'] ?? '',
|
||||
email: data['email'] ?? '',
|
||||
telephone: data['telephone'] ?? '',
|
||||
dateNaissance: dateNaissance,
|
||||
profession: data['profession'],
|
||||
adresse: data['adresse'],
|
||||
ville: data['ville'],
|
||||
pays: data['pays'] ?? 'Côte d\'Ivoire',
|
||||
statut: data['statut'] ?? 'ACTIF',
|
||||
dateAdhesion: dateAdhesion,
|
||||
dateCreation: dateCreation,
|
||||
actif: data['actif'] ?? true,
|
||||
version: data['version'] ?? 1,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur parsing JSON: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Valide un membre importé
|
||||
bool _validateImportedMember(MembreModel member) {
|
||||
// Validation basique
|
||||
if (member.nom.isEmpty || member.prenom.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validation email si fourni
|
||||
if (member.email.isNotEmpty && !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(member.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validation téléphone si fourni
|
||||
if (member.telephone.isNotEmpty && !RegExp(r'^\+?[\d\s\-\(\)]{8,}$').hasMatch(member.telephone)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user