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:
DahoudG
2025-09-15 01:44:16 +00:00
parent 73459b3092
commit f89f6167cc
290 changed files with 34563 additions and 3528 deletions

View File

@@ -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');
}
}
}

View File

@@ -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'),
),
],
),
);
}
}

View File

@@ -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;
}
}