508 lines
17 KiB
Dart
508 lines
17 KiB
Dart
import 'dart:io';
|
|
import 'package:pdf/pdf.dart';
|
|
import 'package:pdf/widgets.dart' as pw;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../models/dashboard_stats_model.dart';
|
|
|
|
/// Service d'export de rapports PDF pour le Dashboard
|
|
class DashboardExportService {
|
|
static const String _reportsFolder = 'UnionFlow_Reports';
|
|
|
|
/// Exporte un rapport complet du dashboard en PDF
|
|
Future<String> exportDashboardReport({
|
|
required DashboardDataModel dashboardData,
|
|
required String organizationName,
|
|
required String reportTitle,
|
|
bool includeCharts = true,
|
|
bool includeActivities = true,
|
|
bool includeEvents = true,
|
|
}) async {
|
|
final pdf = pw.Document();
|
|
|
|
// Charger les polices personnalisées si disponibles
|
|
final font = await _loadFont();
|
|
|
|
// Page 1: Couverture et statistiques principales
|
|
pdf.addPage(
|
|
pw.MultiPage(
|
|
pageFormat: PdfPageFormat.a4,
|
|
theme: _createTheme(font),
|
|
build: (context) => [
|
|
_buildHeader(organizationName, reportTitle),
|
|
pw.SizedBox(height: 20),
|
|
_buildStatsSection(dashboardData.stats),
|
|
pw.SizedBox(height: 20),
|
|
_buildSummarySection(dashboardData),
|
|
],
|
|
),
|
|
);
|
|
|
|
// Page 2: Activités récentes (si incluses)
|
|
if (includeActivities && dashboardData.recentActivities.isNotEmpty) {
|
|
pdf.addPage(
|
|
pw.MultiPage(
|
|
pageFormat: PdfPageFormat.a4,
|
|
theme: _createTheme(font),
|
|
build: (context) => [
|
|
_buildSectionTitle('Activités Récentes'),
|
|
pw.SizedBox(height: 10),
|
|
_buildActivitiesSection(dashboardData.recentActivities),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Page 3: Événements à venir (si inclus)
|
|
if (includeEvents && dashboardData.upcomingEvents.isNotEmpty) {
|
|
pdf.addPage(
|
|
pw.MultiPage(
|
|
pageFormat: PdfPageFormat.a4,
|
|
theme: _createTheme(font),
|
|
build: (context) => [
|
|
_buildSectionTitle('Événements à Venir'),
|
|
pw.SizedBox(height: 10),
|
|
_buildEventsSection(dashboardData.upcomingEvents),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Page 4: Graphiques et analyses (si inclus)
|
|
if (includeCharts) {
|
|
pdf.addPage(
|
|
pw.MultiPage(
|
|
pageFormat: PdfPageFormat.a4,
|
|
theme: _createTheme(font),
|
|
build: (context) => [
|
|
_buildSectionTitle('Analyses et Tendances'),
|
|
pw.SizedBox(height: 10),
|
|
_buildAnalyticsSection(dashboardData.stats),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Sauvegarder le PDF
|
|
final fileName = _generateFileName(reportTitle);
|
|
final filePath = await _savePdf(pdf, fileName);
|
|
|
|
return filePath;
|
|
}
|
|
|
|
/// Exporte uniquement les statistiques en PDF
|
|
Future<String> exportStatsReport({
|
|
required DashboardStatsModel stats,
|
|
required String organizationName,
|
|
String? customTitle,
|
|
}) async {
|
|
final pdf = pw.Document();
|
|
final font = await _loadFont();
|
|
final title = customTitle ?? 'Rapport Statistiques - ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year}';
|
|
|
|
pdf.addPage(
|
|
pw.MultiPage(
|
|
pageFormat: PdfPageFormat.a4,
|
|
theme: _createTheme(font),
|
|
build: (context) => [
|
|
_buildHeader(organizationName, title),
|
|
pw.SizedBox(height: 30),
|
|
_buildStatsSection(stats),
|
|
pw.SizedBox(height: 30),
|
|
_buildStatsAnalysis(stats),
|
|
],
|
|
),
|
|
);
|
|
|
|
final fileName = _generateFileName('Stats_${DateTime.now().millisecondsSinceEpoch}');
|
|
final filePath = await _savePdf(pdf, fileName);
|
|
|
|
return filePath;
|
|
}
|
|
|
|
/// Charge une police personnalisée
|
|
Future<pw.Font?> _loadFont() async {
|
|
try {
|
|
final fontData = await rootBundle.load('assets/fonts/Inter-Regular.ttf');
|
|
return pw.Font.ttf(fontData);
|
|
} catch (e) {
|
|
// Police par défaut si la police personnalisée n'est pas disponible
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Crée le thème PDF
|
|
pw.ThemeData _createTheme(pw.Font? font) {
|
|
return pw.ThemeData.withFont(
|
|
base: font ?? pw.Font.helvetica(),
|
|
bold: font ?? pw.Font.helveticaBold(),
|
|
);
|
|
}
|
|
|
|
/// Construit l'en-tête du rapport
|
|
pw.Widget _buildHeader(String organizationName, String reportTitle) {
|
|
return pw.Container(
|
|
width: double.infinity,
|
|
padding: const pw.EdgeInsets.all(20),
|
|
decoration: pw.BoxDecoration(
|
|
gradient: pw.LinearGradient(
|
|
colors: [
|
|
PdfColor.fromHex('#4169E1'), // Bleu Roi
|
|
PdfColor.fromHex('#008B8B'), // Bleu Pétrole
|
|
],
|
|
),
|
|
borderRadius: pw.BorderRadius.circular(10),
|
|
),
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text(
|
|
organizationName,
|
|
style: pw.TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: PdfColors.white,
|
|
),
|
|
),
|
|
pw.SizedBox(height: 5),
|
|
pw.Text(
|
|
reportTitle,
|
|
style: const pw.TextStyle(
|
|
fontSize: 16,
|
|
color: PdfColors.white,
|
|
),
|
|
),
|
|
pw.SizedBox(height: 10),
|
|
pw.Text(
|
|
'Généré le ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year} à ${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}',
|
|
style: const pw.TextStyle(
|
|
fontSize: 12,
|
|
color: PdfColors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construit la section des statistiques
|
|
pw.Widget _buildStatsSection(DashboardStatsModel stats) {
|
|
return pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionTitle('Statistiques Principales'),
|
|
pw.SizedBox(height: 15),
|
|
pw.Row(
|
|
children: [
|
|
pw.Expanded(
|
|
child: _buildStatCard('Membres Total', stats.totalMembers.toString(), PdfColor.fromHex('#4169E1')),
|
|
),
|
|
pw.SizedBox(width: 10),
|
|
pw.Expanded(
|
|
child: _buildStatCard('Membres Actifs', stats.activeMembers.toString(), PdfColor.fromHex('#10B981')),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 10),
|
|
pw.Row(
|
|
children: [
|
|
pw.Expanded(
|
|
child: _buildStatCard('Événements', stats.totalEvents.toString(), PdfColor.fromHex('#008B8B')),
|
|
),
|
|
pw.SizedBox(width: 10),
|
|
pw.Expanded(
|
|
child: _buildStatCard('Contributions', stats.formattedContributionAmount, PdfColor.fromHex('#F59E0B')),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 10),
|
|
pw.Row(
|
|
children: [
|
|
pw.Expanded(
|
|
child: _buildStatCard('Croissance', '${stats.monthlyGrowth.toStringAsFixed(1)}%',
|
|
stats.hasGrowth ? PdfColor.fromHex('#10B981') : PdfColor.fromHex('#EF4444')),
|
|
),
|
|
pw.SizedBox(width: 10),
|
|
pw.Expanded(
|
|
child: _buildStatCard('Engagement', '${(stats.engagementRate * 100).toStringAsFixed(1)}%',
|
|
stats.isHighEngagement ? PdfColor.fromHex('#10B981') : PdfColor.fromHex('#F59E0B')),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Construit une carte de statistique
|
|
pw.Widget _buildStatCard(String title, String value, PdfColor color) {
|
|
return pw.Container(
|
|
padding: const pw.EdgeInsets.all(15),
|
|
decoration: pw.BoxDecoration(
|
|
border: pw.Border.all(color: color, width: 2),
|
|
borderRadius: pw.BorderRadius.circular(8),
|
|
),
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text(
|
|
title,
|
|
style: const pw.TextStyle(
|
|
fontSize: 12,
|
|
color: PdfColors.grey700,
|
|
),
|
|
),
|
|
pw.SizedBox(height: 5),
|
|
pw.Text(
|
|
value,
|
|
style: pw.TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construit un titre de section
|
|
pw.Widget _buildSectionTitle(String title) {
|
|
return pw.Text(
|
|
title,
|
|
style: pw.TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: pw.FontWeight.bold,
|
|
color: PdfColor.fromHex('#1F2937'),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construit la section de résumé
|
|
pw.Widget _buildSummarySection(DashboardDataModel data) {
|
|
return pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionTitle('Résumé Exécutif'),
|
|
pw.SizedBox(height: 10),
|
|
pw.Text(
|
|
'Ce rapport présente un aperçu complet de l\'activité de l\'organisation. '
|
|
'Avec ${data.stats.totalMembers} membres dont ${data.stats.activeMembers} actifs '
|
|
'(${data.stats.activeMemberPercentage.toStringAsFixed(1)}%), l\'organisation maintient '
|
|
'un niveau d\'engagement de ${(data.stats.engagementRate * 100).toStringAsFixed(1)}%.',
|
|
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
|
|
),
|
|
pw.SizedBox(height: 10),
|
|
pw.Text(
|
|
'La croissance mensuelle de ${data.stats.monthlyGrowth.toStringAsFixed(1)}% '
|
|
'${data.stats.hasGrowth ? 'indique une tendance positive' : 'nécessite une attention particulière'}. '
|
|
'Les contributions totales s\'élèvent à ${data.stats.formattedContributionAmount} XOF.',
|
|
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Construit la section des activités
|
|
pw.Widget _buildActivitiesSection(List<RecentActivityModel> activities) {
|
|
return pw.Table(
|
|
border: pw.TableBorder.all(color: PdfColors.grey300),
|
|
children: [
|
|
// En-tête
|
|
pw.TableRow(
|
|
decoration: pw.BoxDecoration(color: PdfColor.fromHex('#F3F4F6')),
|
|
children: [
|
|
_buildTableHeader('Type'),
|
|
_buildTableHeader('Description'),
|
|
_buildTableHeader('Utilisateur'),
|
|
_buildTableHeader('Date'),
|
|
],
|
|
),
|
|
// Données
|
|
...activities.take(10).map((activity) => pw.TableRow(
|
|
children: [
|
|
_buildTableCell(activity.type),
|
|
_buildTableCell(activity.title),
|
|
_buildTableCell(activity.userName),
|
|
_buildTableCell(activity.timeAgo),
|
|
],
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Construit la section des événements
|
|
pw.Widget _buildEventsSection(List<UpcomingEventModel> events) {
|
|
return pw.Table(
|
|
border: pw.TableBorder.all(color: PdfColors.grey300),
|
|
children: [
|
|
// En-tête
|
|
pw.TableRow(
|
|
decoration: pw.BoxDecoration(color: PdfColor.fromHex('#F3F4F6')),
|
|
children: [
|
|
_buildTableHeader('Événement'),
|
|
_buildTableHeader('Date'),
|
|
_buildTableHeader('Lieu'),
|
|
_buildTableHeader('Participants'),
|
|
],
|
|
),
|
|
// Données
|
|
...events.take(10).map((event) => pw.TableRow(
|
|
children: [
|
|
_buildTableCell(event.title),
|
|
_buildTableCell('${event.startDate.day}/${event.startDate.month}'),
|
|
_buildTableCell(event.location),
|
|
_buildTableCell('${event.currentParticipants}/${event.maxParticipants}'),
|
|
],
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Construit l'en-tête de tableau
|
|
pw.Widget _buildTableHeader(String text) {
|
|
return pw.Padding(
|
|
padding: const pw.EdgeInsets.all(8),
|
|
child: pw.Text(
|
|
text,
|
|
style: pw.TextStyle(
|
|
fontWeight: pw.FontWeight.bold,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construit une cellule de tableau
|
|
pw.Widget _buildTableCell(String text) {
|
|
return pw.Padding(
|
|
padding: const pw.EdgeInsets.all(8),
|
|
child: pw.Text(
|
|
text,
|
|
style: const pw.TextStyle(fontSize: 9),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construit la section d'analyse des statistiques
|
|
pw.Widget _buildStatsAnalysis(DashboardStatsModel stats) {
|
|
return pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionTitle('Analyse des Performances'),
|
|
pw.SizedBox(height: 10),
|
|
_buildAnalysisPoint('Taux d\'activité des membres',
|
|
'${stats.activeMemberPercentage.toStringAsFixed(1)}%',
|
|
stats.activeMemberPercentage > 70 ? 'Excellent' : 'À améliorer'),
|
|
_buildAnalysisPoint('Croissance mensuelle',
|
|
'${stats.monthlyGrowth.toStringAsFixed(1)}%',
|
|
stats.hasGrowth ? 'Positive' : 'Négative'),
|
|
_buildAnalysisPoint('Niveau d\'engagement',
|
|
'${(stats.engagementRate * 100).toStringAsFixed(1)}%',
|
|
stats.isHighEngagement ? 'Élevé' : 'Modéré'),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Construit un point d'analyse
|
|
pw.Widget _buildAnalysisPoint(String metric, String value, String assessment) {
|
|
return pw.Padding(
|
|
padding: const pw.EdgeInsets.symmetric(vertical: 5),
|
|
child: pw.Row(
|
|
children: [
|
|
pw.Expanded(flex: 2, child: pw.Text(metric, style: const pw.TextStyle(fontSize: 11))),
|
|
pw.Expanded(flex: 1, child: pw.Text(value, style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold))),
|
|
pw.Expanded(flex: 1, child: pw.Text(assessment, style: const pw.TextStyle(fontSize: 11))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construit la section d'analytics
|
|
pw.Widget _buildAnalyticsSection(DashboardStatsModel stats) {
|
|
return pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text('Tendances et Projections', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
|
pw.SizedBox(height: 15),
|
|
pw.Text('Basé sur les données actuelles, voici les principales tendances observées:', style: const pw.TextStyle(fontSize: 11)),
|
|
pw.SizedBox(height: 10),
|
|
pw.Bullet(text: 'Évolution du nombre de membres: ${stats.hasGrowth ? 'Croissance' : 'Déclin'} de ${stats.monthlyGrowth.abs().toStringAsFixed(1)}% ce mois'),
|
|
pw.Bullet(text: 'Participation aux événements: ${stats.upcomingEvents} événements programmés'),
|
|
pw.Bullet(text: 'Volume des contributions: ${stats.formattedContributionAmount} XOF collectés'),
|
|
pw.Bullet(text: 'Demandes en attente: ${stats.pendingRequests} nécessitent un traitement'),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Génère un nom de fichier unique
|
|
String _generateFileName(String baseName) {
|
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
final cleanName = baseName.replaceAll(RegExp(r'[^\w\s-]'), '').replaceAll(' ', '_');
|
|
return '${cleanName}_$timestamp.pdf';
|
|
}
|
|
|
|
/// Sauvegarde le PDF et retourne le chemin
|
|
Future<String> _savePdf(pw.Document pdf, String fileName) async {
|
|
final directory = await getApplicationDocumentsDirectory();
|
|
final reportsDir = Directory('${directory.path}/$_reportsFolder');
|
|
|
|
if (!await reportsDir.exists()) {
|
|
await reportsDir.create(recursive: true);
|
|
}
|
|
|
|
final file = File('${reportsDir.path}/$fileName');
|
|
await file.writeAsBytes(await pdf.save());
|
|
|
|
return file.path;
|
|
}
|
|
|
|
/// Partage un rapport PDF
|
|
Future<void> shareReport(String filePath, {String? subject}) async {
|
|
await Share.shareXFiles(
|
|
[XFile(filePath)],
|
|
subject: subject ?? 'Rapport Dashboard UnionFlow',
|
|
text: 'Rapport généré par l\'application UnionFlow',
|
|
);
|
|
}
|
|
|
|
/// Obtient la liste des rapports sauvegardés
|
|
Future<List<File>> getSavedReports() async {
|
|
final directory = await getApplicationDocumentsDirectory();
|
|
final reportsDir = Directory('${directory.path}/$_reportsFolder');
|
|
|
|
if (!await reportsDir.exists()) {
|
|
return [];
|
|
}
|
|
|
|
final files = await reportsDir.list().where((entity) =>
|
|
entity is File && entity.path.endsWith('.pdf')).cast<File>().toList();
|
|
|
|
// Trier par date de modification (plus récent en premier)
|
|
files.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
|
|
|
|
return files;
|
|
}
|
|
|
|
/// Supprime un rapport
|
|
Future<void> deleteReport(String filePath) async {
|
|
final file = File(filePath);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
}
|
|
|
|
/// Supprime tous les rapports anciens (plus de 30 jours)
|
|
Future<void> cleanupOldReports() async {
|
|
final reports = await getSavedReports();
|
|
final cutoffDate = DateTime.now().subtract(const Duration(days: 30));
|
|
|
|
for (final report in reports) {
|
|
final lastModified = await report.lastModified();
|
|
if (lastModified.isBefore(cutoffDate)) {
|
|
await report.delete();
|
|
}
|
|
}
|
|
}
|
|
}
|