manager-app/lib/Screens/Statistics/statistics_report.dart
Thomas Fransolet bb4d4fe2d5 Écran Statistiques refondu + Guide IA + onboarding (auth, abonnement) + i18n FR/EN/NL
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.
2026-08-09 22:14:37 +02:00

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