pw.Container délègue canSpan à son enfant, si bien qu'une carte pouvait se scinder entre deux pages — titre seul en bas de l'une, contenu en haut de l'autre. Enveloppe le graphique et les groupes de barres dans un widget non sécable, que MultiPage reporte alors en entier. Les tableaux restent sécables : un tableau long doit pouvoir se répartir sur plusieurs pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
491 lines
15 KiB
Dart
491 lines
15 KiB
Dart
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<ReportBar> bars;
|
|
}
|
|
|
|
class ReportTable {
|
|
const ReportTable(this.title, this.headers, this.rows);
|
|
|
|
final String title;
|
|
final List<String> headers;
|
|
final List<List<String>> 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<String> takeaways;
|
|
final List<ReportKpi> kpis;
|
|
final String chartTitle;
|
|
final String chartSubtitle;
|
|
final List<ReportDayPoint> series;
|
|
|
|
/// Index des points de la série qui portent une étiquette sur l'axe des X.
|
|
final List<int> axisLabels;
|
|
final String? peakLabel;
|
|
final List<ReportBarGroup> barGroups;
|
|
final List<ReportTable> 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<Uint8List?> 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<Uint8List> 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 _Unbreakable(_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<int>(
|
|
data.axisLabels,
|
|
format: (value) => data.series[value as int].label,
|
|
textStyle: const pw.TextStyle(fontSize: 7.5, color: _ink3),
|
|
color: _line,
|
|
),
|
|
yAxis: pw.FixedAxis<int>(
|
|
[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 _Unbreakable(_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()),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// `Container` délègue `canSpan` à son enfant : une carte se coupait donc entre
|
|
/// deux pages, titre seul en bas de l'une et contenu en haut de l'autre.
|
|
/// `MultiPage` reporte en entier tout enfant non sécable.
|
|
class _Unbreakable extends pw.SingleChildWidget {
|
|
_Unbreakable(pw.Widget child) : super(child: child);
|
|
|
|
@override
|
|
bool get canSpan => false;
|
|
|
|
@override
|
|
void paint(pw.Context context) {
|
|
super.paint(context);
|
|
paintChild(context);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|