Statistiques — refonte complète, aucun changement backend statistics_screen.dart réécrit (+1832) : barres horizontales monochromes à la place des barres verticales tronquées et des deux anneaux, barre de filtres unique avec les volumes par canal, règle mono-canal, 4 KPI portant chacun leur variation, bandeau « à retenir », courbe en aire avec bandes de week-end. La période précédente s'obtient en rappelant le même endpoint. statistics_report.dart : export PDF généré côté client (paquet pdf Dart), il partage les valeurs calculées de l'écran — un chiffre ne peut pas diverger entre l'écran et le document envoyé à la commune. Deux puces du sommaire promettaient des données inexistantes (parcours terminés, questions au guide IA), retirées. ⚠️ Jamais ouvert dans un navigateur. Cases de test : test-plan.md §8bis / §8ter. Guide IA Screens/GuideIa/guide_ia_screen.dart — onglet Configuration. Menu conditionné à isAssistant, le même drapeau que la garde d'AiController. L'onglet « Ce que demandent vos visiteurs » n'est pas dans ce commit : le schéma backend est prêt, l'UI non. Onboarding self-service Screens/Auth/ (mot de passe oublié, définition du mot de passe), Screens/Billing/subscription_screen.dart, ai_quota_hint.dart. ⚠️ Aucun parcours joué de bout en bout — test-plan.md §18. Parcours guidés progression_mode.dart : 9 booléens sur 3 niveaux remplacés par 3 questions. Popups GuidedPath / GuidedStep / QuizQuestion mises à jour en conséquence. Client API (manager_api_new) — édité À LA MAIN, ne pas relancer la génération onboarding_api.dart, authentication_api.dart (+80), instance_dto (champs Guide*), guided_step / quiz_question_guided_step (flags morts retirés). Le // @dart=2.18 manquant dans onboarding_api.dart cassait les 3 apps Flutter d'un coup — corrigé ici. i18n : ~180 clés par langue (FR/EN/NL) + fichiers générés. Tests : progression_mode_test, statistics_report_test (le second a attrapé deux plantages qui seraient sortis au premier clic). flutter build web ✅. flutter analyze : 68 erreurs, toutes dans les fichiers modèle orphelins de manager_api_new — dette connue, pas une régression, ces fichiers ne sont pas dans le graphe de compilation.
475 lines
14 KiB
Dart
475 lines
14 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 _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 _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;
|
|
}
|