import 'dart:typed_data'; import 'package:flutter/services.dart' show rootBundle; import 'package:http/http.dart' as http; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; /// Rapport de fréquentation, généré côté client à partir des agrégats déjà /// chargés par l'écran. L'envoi mensuel automatique, lui, devra être fait côté /// backend (QuestPDF + Hangfire) — voir `plan-import-ia-stats-subsides.md §1`. class ReportKpi { const ReportKpi(this.label, this.value, this.trend); final String label; final String value; final String? trend; } class ReportBar { const ReportBar(this.label, this.value, this.fraction); final String label; final String value; final double fraction; } class ReportBarGroup { const ReportBarGroup(this.title, this.subtitle, this.bars); final String title; final String subtitle; final List bars; } class ReportTable { const ReportTable(this.title, this.headers, this.rows); final String title; final List headers; final List> rows; } class ReportDayPoint { const ReportDayPoint(this.label, this.visits); final String label; final int visits; } class StatisticsReportData { const StatisticsReportData({ required this.instanceName, required this.title, required this.periodLabel, required this.takeaways, required this.kpis, required this.chartTitle, required this.chartSubtitle, required this.series, required this.axisLabels, required this.peakLabel, required this.barGroups, required this.tables, required this.generatedLabel, required this.brandArgb, required this.logo, }); final String instanceName; final String title; final String periodLabel; final List takeaways; final List kpis; final String chartTitle; final String chartSubtitle; final List series; /// Index des points de la série qui portent une étiquette sur l'axe des X. final List axisLabels; final String? peakLabel; final List barGroups; final List tables; final String generatedLabel; /// Couleur principale du canal de référence, en ARGB — l'écran n'a pas à /// connaître les types du paquet `pdf`. final int brandArgb; final Uint8List? logo; PdfColor get brandColor => PdfColor.fromInt(brandArgb); } const _ink = PdfColor.fromInt(0xFF14202B); const _ink2 = PdfColor.fromInt(0xFF4A5A6B); const _ink3 = PdfColor.fromInt(0xFF7C8B9A); const _line = PdfColor.fromInt(0xFFD6DDE5); const _track = PdfColor.fromInt(0xFFE7EBF1); const _muted = PdfColor.fromInt(0xFFF3F5F8); /// Le logo du client vit sur Firebase Storage. Sans en-tête CORS sur le bucket, /// le navigateur refuse la lecture des octets : on retombe alors sur une page de /// garde au seul nom de l'instance plutôt que de faire échouer le rapport. Future fetchReportLogo(String? url) async { if (url == null || url.isEmpty) return null; try { final response = await http.get(Uri.parse(url)); if (response.statusCode != 200) return null; return response.bodyBytes; } catch (_) { return null; } } Future buildStatisticsReport(StatisticsReportData data) async { final fontData = await rootBundle.load('assets/fonts/OpenSans-Medium.ttf'); final font = pw.Font.ttf(fontData); final document = pw.Document( theme: pw.ThemeData.withFont(base: font, bold: font, italic: font), ); document.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, margin: const pw.EdgeInsets.fromLTRB(38, 34, 38, 34), header: (context) => context.pageNumber == 1 ? pw.SizedBox() : _runningHeader(data), footer: (context) => _footer(data, context), build: (context) => [ _cover(data), if (data.takeaways.isNotEmpty) ...[ pw.SizedBox(height: 22), _takeaways(data), ], pw.SizedBox(height: 18), _kpiRow(data), // Sous deux points il n'y a pas de courbe à tracer, et l'axe des X // dégénère en division par zéro. if (data.series.length >= 2) ...[ pw.SizedBox(height: 22), _chart(data), ], for (final group in data.barGroups) ...[ pw.SizedBox(height: 22), _barGroup(data, group), ], for (final table in data.tables) ...[ pw.SizedBox(height: 22), _table(data, table), ], ], ), ); return document.save(); } pw.Widget _cover(StatisticsReportData data) { return pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text( data.instanceName, style: pw.TextStyle( fontSize: 11, color: data.brandColor, letterSpacing: 1.2), ), pw.SizedBox(height: 6), pw.Text(data.title, style: const pw.TextStyle(fontSize: 25, color: _ink)), pw.SizedBox(height: 5), pw.Text(data.periodLabel, style: const pw.TextStyle(fontSize: 12, color: _ink3)), ], ), ), if (data.logo != null) pw.Container( height: 52, constraints: const pw.BoxConstraints(maxWidth: 130), child: pw.Image(pw.MemoryImage(data.logo!), fit: pw.BoxFit.contain), ), ], ), pw.SizedBox(height: 14), pw.Container(height: 2, color: data.brandColor), ], ); } pw.Widget _runningHeader(StatisticsReportData data) { return pw.Container( margin: const pw.EdgeInsets.only(bottom: 16), padding: const pw.EdgeInsets.only(bottom: 6), decoration: const pw.BoxDecoration( border: pw.Border(bottom: pw.BorderSide(color: _line)), ), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text(data.instanceName, style: const pw.TextStyle(fontSize: 9, color: _ink3)), pw.Text(data.periodLabel, style: const pw.TextStyle(fontSize: 9, color: _ink3)), ], ), ); } pw.Widget _footer(StatisticsReportData data, pw.Context context) { return pw.Container( margin: const pw.EdgeInsets.only(top: 14), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text(data.generatedLabel, style: const pw.TextStyle(fontSize: 8.5, color: _ink3)), pw.Text('${context.pageNumber} / ${context.pagesCount}', style: const pw.TextStyle(fontSize: 8.5, color: _ink3)), ], ), ); } pw.Widget _takeaways(StatisticsReportData data) { return pw.Container( width: double.infinity, padding: const pw.EdgeInsets.symmetric(horizontal: 15, vertical: 13), decoration: pw.BoxDecoration( color: _muted, border: pw.Border.all(color: _line), borderRadius: pw.BorderRadius.circular(5), ), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ for (final sentence in data.takeaways) pw.Padding( padding: pw.EdgeInsets.only( top: sentence == data.takeaways.first ? 0 : 5), child: pw.Text(sentence, style: const pw.TextStyle( fontSize: 11, color: _ink, lineSpacing: 2.5)), ), ], ), ); } /// Hauteur fixe assumée : `MultiPage` pose ses enfants sans contrainte /// verticale, et `CrossAxisAlignment.stretch` y donnerait une hauteur infinie. /// Le paquet `pdf` n'a pas d'`IntrinsicHeight` pour égaliser autrement. pw.Widget _kpiRow(StatisticsReportData data) { return pw.SizedBox( height: 84, child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ for (final kpi in data.kpis) ...[ pw.Expanded( child: pw.Container( padding: const pw.EdgeInsets.fromLTRB(12, 11, 12, 12), decoration: pw.BoxDecoration(border: pw.Border.all(color: _line)), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text(kpi.label.toUpperCase(), maxLines: 2, style: const pw.TextStyle( fontSize: 7.5, color: _ink3, letterSpacing: 0.4)), pw.SizedBox(height: 6), pw.Text(kpi.value, maxLines: 1, style: pw.TextStyle(fontSize: 18, color: data.brandColor)), if (kpi.trend != null) ...[ pw.SizedBox(height: 4), pw.Text(kpi.trend!, maxLines: 1, style: const pw.TextStyle(fontSize: 8, color: _ink2)), ], ], ), ), ), if (kpi != data.kpis.last) pw.SizedBox(width: 8), ], ], ), ); } pw.Widget _chart(StatisticsReportData data) { final peak = data.series.map((point) => point.visits).reduce((a, b) => a > b ? a : b); final axisMax = _roundedAxisMax(peak); return _card( data, title: data.chartTitle, subtitle: data.chartSubtitle, child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.SizedBox( height: 150, child: pw.Chart( grid: pw.CartesianGrid( xAxis: pw.FixedAxis( data.axisLabels, format: (value) => data.series[value as int].label, textStyle: const pw.TextStyle(fontSize: 7.5, color: _ink3), color: _line, ), yAxis: pw.FixedAxis( [0, (axisMax / 2).round(), axisMax.round()], divisions: true, divisionsColor: _line, textStyle: const pw.TextStyle(fontSize: 7.5, color: _ink3), color: _line, ), ), datasets: [ pw.LineDataSet( data: [ for (var i = 0; i < data.series.length; i++) pw.PointChartValue( i.toDouble(), data.series[i].visits.toDouble()), ], color: data.brandColor, lineWidth: 1.4, drawPoints: false, drawSurface: true, surfaceColor: data.brandColor, surfaceOpacity: 0.16, ), ], ), ), if (data.peakLabel != null) ...[ pw.SizedBox(height: 8), pw.Text(data.peakLabel!, style: const pw.TextStyle(fontSize: 9, color: _ink3)), ], ], ), ); } pw.Widget _barGroup(StatisticsReportData data, ReportBarGroup group) { return _card( data, title: group.title, subtitle: group.subtitle, child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ for (final bar in group.bars) pw.Padding( padding: pw.EdgeInsets.only(bottom: bar == group.bars.last ? 0 : 9), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ pw.Row( children: [ pw.Expanded( child: pw.Text(bar.label, maxLines: 1, style: const pw.TextStyle(fontSize: 10, color: _ink)), ), pw.SizedBox(width: 10), pw.Text(bar.value, style: const pw.TextStyle(fontSize: 9.5, color: _ink2)), ], ), pw.SizedBox(height: 4), _barTrack(bar.fraction, data.brandColor), ], ), ), ], ), ); } pw.Widget _table(StatisticsReportData data, ReportTable table) { return _card( data, title: table.title, subtitle: null, child: pw.TableHelper.fromTextArray( headers: table.headers, data: table.rows, border: null, headerStyle: const pw.TextStyle(fontSize: 9, color: _ink3), headerDecoration: const pw.BoxDecoration( border: pw.Border(bottom: pw.BorderSide(color: _line)), ), cellStyle: const pw.TextStyle(fontSize: 10, color: _ink), cellAlignment: pw.Alignment.centerLeft, cellHeight: 20, headerPadding: const pw.EdgeInsets.only(bottom: 6), cellPadding: const pw.EdgeInsets.symmetric(vertical: 3), ), ); } pw.Widget _card( StatisticsReportData data, { required String title, required String? subtitle, required pw.Widget child, }) { return pw.Container( width: double.infinity, padding: const pw.EdgeInsets.fromLTRB(15, 13, 15, 15), decoration: pw.BoxDecoration(border: pw.Border.all(color: _line)), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text(title, style: pw.TextStyle(fontSize: 12, color: data.brandColor)), if (subtitle != null) ...[ pw.SizedBox(height: 2), pw.Text(subtitle, style: const pw.TextStyle(fontSize: 9, color: _ink3)), ], pw.SizedBox(height: 12), child, ], ), ); } /// Le paquet `pdf` n'a pas d'équivalent de `FractionallySizedBox` : la /// proportion se rend avec deux `Expanded` sur un millième près. pw.Widget _barTrack(double fraction, PdfColor color) { const steps = 1000; final filled = (fraction.clamp(0.0, 1.0) * steps).round(); if (filled >= steps) return pw.Container(height: 7, color: color); return pw.Container( height: 7, color: _track, child: pw.Row( children: [ if (filled > 0) pw.Expanded(flex: filled, child: pw.Container(color: color)), pw.Expanded(flex: steps - filled, child: pw.SizedBox()), ], ), ); } double _roundedAxisMax(int peak) { if (peak <= 4) return 4; var magnitude = 1.0; while (magnitude * 10 <= peak) { magnitude *= 10; } for (final step in const [1.0, 2.0, 4.0, 10.0]) { final candidate = magnitude * step; if (candidate >= peak) return candidate; } return magnitude * 10; }