manager-app/lib/Screens/Statistics/statistics_screen.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

1482 lines
49 KiB
Dart

import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/Helpers/PDFHelper.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/Screens/Statistics/statistics_report.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
const _kUp = Color(0xFF1F6B4D);
const _kDown = Color(0xFFA8322A);
const _kTrack = Color(0xFFE7EBF1);
const _kMuted = Color(0xFFF3F5F8);
const _kLine = Color(0xFFD6DDE5);
/// Périodes proposées, en jours. `365` s'affiche « Année ».
const _kPeriods = [7, 30, 90, 365];
/// Au-delà de cette longueur, les bandes de week-end deviennent du bruit visuel.
const _kMaxDaysForWeekendBands = 62;
class StatisticsScreen extends StatefulWidget {
const StatisticsScreen({super.key});
@override
State<StatisticsScreen> createState() => _StatisticsScreenState();
}
class _StatisticsScreenState extends State<StatisticsScreen> {
int _selectedDays = 30;
AppType? _selectedChannel;
bool _loading = true;
bool _failed = false;
bool _exporting = false;
/// Période courante, filtrée sur le canal sélectionné.
StatsSummaryDTO? _current;
/// Même durée, juste avant : sert uniquement aux variations des KPI.
StatsSummaryDTO? _previous;
/// Période courante tous canaux confondus : volumes des chips de canal.
StatsSummaryDTO? _allChannels;
DateTime _from = DateTime.now();
DateTime _to = DateTime.now();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
ManagerAppContext get _ctx =>
Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
bool get _hasStats => _ctx.instanceDTO?.hasStats ?? false;
bool get _hasAdvancedStats => _ctx.instanceDTO?.hasAdvancedStats ?? false;
int get _statsHistoryDays => _ctx.instanceDTO?.statsHistoryDays ?? 30;
/// La période précédente n'est comparable que si l'historique du plan la couvre
/// entièrement : sinon le backend la tronque et la variation serait fausse.
bool get _canComparePrevious =>
_statsHistoryDays == 0 || _statsHistoryDays >= _selectedDays * 2;
/// Canaux de l'instance : ceux qui sont déclarés, plus ceux qui ont produit des
/// visites (un canal qui a de la donnée ne doit jamais disparaître du filtre).
List<AppType> get _channels {
final declared = (_ctx.instanceDTO?.applicationInstanceDTOs ?? const [])
.map((app) => app.appType)
.whereType<AppType>();
final withData = AppType.values
.where((type) => (_allChannels?.appTypeDistribution[type.name] ?? 0) > 0);
final present = {...declared, ...withData};
return AppType.values.where(present.contains).toList();
}
bool get _isMultiChannel => _channels.length >= 2;
Future<void> _load() async {
final instanceId = _ctx.instanceId;
final api = _ctx.clientAPI?.statsApi;
if (instanceId == null || api == null || !_hasStats) {
setState(() => _loading = false);
return;
}
final to = DateTime.now();
final from = to.subtract(Duration(days: _selectedDays));
final channel = _selectedChannel?.name;
setState(() {
_loading = true;
_failed = false;
_from = from;
_to = to;
});
try {
final results = await Future.wait<StatsSummaryDTO?>([
api.statsGetSummary(instanceId, from: from, to: to, appType: channel),
if (_canComparePrevious)
api.statsGetSummary(
instanceId,
from: from.subtract(Duration(days: _selectedDays)),
to: from,
appType: channel,
)
else
Future.value(),
if (channel != null)
api.statsGetSummary(instanceId, from: from, to: to)
else
Future.value(),
]);
if (!mounted) return;
setState(() {
_current = results[0];
_previous = results[1];
_allChannels = results[2] ?? results[0];
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_failed = true;
_loading = false;
});
}
}
void _selectPeriod(int days) {
if (days == _selectedDays) return;
_selectedDays = days;
_load();
}
void _selectChannel(AppType? channel) {
if (channel == _selectedChannel) return;
_selectedChannel = channel;
_load();
}
// --- Mise en forme -------------------------------------------------------
NumberFormat get _numbers =>
NumberFormat.decimalPattern(Localizations.localeOf(context).languageCode);
String _count(num value) => _numbers.format(value);
String _oneDecimal(double value) =>
NumberFormat('0.#', Localizations.localeOf(context).languageCode).format(value);
String _formatDuration(int seconds) {
final l = AppLocalizations.of(context)!;
if (seconds < 60) return l.statsDurationSec('$seconds');
return l.statsDurationMinSec('${seconds ~/ 60}', '${seconds % 60}');
}
String _shortDate(DateTime date) =>
MaterialLocalizations.of(context).formatShortMonthDay(date);
String _plainTitle(String? raw, String? fallback) {
final text = (raw ?? '').replaceAll(RegExp(r'<[^>]*>'), '').trim();
return text.isNotEmpty ? text : (fallback ?? '');
}
String _channelLabel(AppType channel) {
final l = AppLocalizations.of(context)!;
switch (channel.value) {
case 0:
return l.statsChannelMobile;
case 1:
return l.statsChannelTablet;
case 2:
return l.statsChannelWeb;
case 3:
return l.statsChannelVR;
case 4:
return l.statsChannelVoice;
default:
return channel.name;
}
}
// --- Agrégats dérivés ----------------------------------------------------
/// Toutes les consultations de contenu de la période (`visitsByDay` compte les
/// `SectionView`), pas seulement celles du top 10.
int _totalViews(StatsSummaryDTO stats) =>
stats.visitsByDay.fold(0, (sum, day) => sum + day.total);
double _contentsPerVisit(StatsSummaryDTO stats) =>
stats.totalSessions == 0 ? 0 : _totalViews(stats) / stats.totalSessions;
int _voiceSessions(StatsSummaryDTO stats) =>
stats.appTypeDistribution[AppType.Voice.name] ?? 0;
double _voiceShare(StatsSummaryDTO stats) => stats.totalSessions == 0
? 0
: _voiceSessions(stats) / stats.totalSessions * 100;
/// Série continue du premier au dernier jour : le backend n'émet que les jours
/// qui ont des événements, et les trous fausseraient la courbe et les week-ends.
List<_DayPoint> _dailySeries(StatsSummaryDTO stats) {
final byDate = {for (final day in stats.visitsByDay) day.date: day.total};
final start = DateTime(_from.year, _from.month, _from.day);
final end = DateTime(_to.year, _to.month, _to.day);
final points = <_DayPoint>[];
for (var offset = 0; ; offset++) {
final date = DateTime(start.year, start.month, start.day + offset);
if (date.isAfter(end)) break;
final key = '${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}';
points.add(_DayPoint(date, byDate[key] ?? 0));
}
return points;
}
// --- Construction --------------------------------------------------------
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
if (!_hasStats && !_loading) {
return _emptyMessage(
Icons.lock_outline,
l.statsUnavailableTitle,
l.statsUnavailableBody,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(l),
const SizedBox(height: 14),
_filters(l),
const SizedBox(height: 4),
Expanded(child: _body(l)),
],
);
}
Widget _body(AppLocalizations l) {
if (_loading) return const CommonLoader();
if (_failed) return _emptyMessage(Icons.cloud_off_outlined, l.statsLoadError, null);
final stats = _current;
if (stats == null || stats.totalSessions == 0) {
return _emptyMessage(
Icons.bar_chart_outlined,
l.statsNoData,
_selectedChannel == null ? null : l.statsNoDataForType(_channelLabel(_selectedChannel!)),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 1040;
return SingleChildScrollView(
padding: const EdgeInsets.only(top: 16, bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_takeaway(l, stats),
const SizedBox(height: 16),
_kpiRow(l, stats, wide),
const SizedBox(height: 16),
_trendCard(l, stats),
const SizedBox(height: 16),
_distributionRow(l, stats, wide),
const SizedBox(height: 16),
_advancedSection(l, stats),
_reportCard(l, stats),
],
),
);
},
);
}
Widget _header(AppLocalizations l) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.statsAttendanceTitle,
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600, color: kPrimaryColor),
),
const SizedBox(height: 3),
Text(
l.statsPeriodRange(_shortDate(_from), _shortDate(_to)),
style: TextStyle(fontSize: 14, color: kBodyTextColor.withValues(alpha: 0.75)),
),
],
);
}
/// Période et canal sur la même ligne, même traitement visuel : ce sont deux
/// filtres de même nature. Chaque canal porte son volume, pour qu'on voie où il
/// y a de la matière avant de cliquer.
Widget _filters(AppLocalizations l) {
final channels = _channels;
final total = _allChannels?.totalSessions ?? _current?.totalSessions ?? 0;
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: _kLine),
bottom: BorderSide(color: _kLine),
),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_filterLabel(l.statsFilterPeriod),
..._kPeriods.map((days) => _filterChip(
label: days == 365 ? l.statsPeriodYear : l.statsPeriodDays(days),
selected: _selectedDays == days,
enabled: _statsHistoryDays == 0 || _statsHistoryDays >= days,
onTap: () => _selectPeriod(days),
)),
if (_isMultiChannel) ...[
const SizedBox(width: 4),
_filterLabel(l.statsFilterChannel),
_filterChip(
label: l.statsAll,
count: total,
selected: _selectedChannel == null,
onTap: () => _selectChannel(null),
),
...channels.map((channel) => _filterChip(
label: _channelLabel(channel),
count: _allChannels?.appTypeDistribution[channel.name] ?? 0,
selected: _selectedChannel == channel,
onTap: () => _selectChannel(channel),
)),
],
],
),
);
}
Widget _filterLabel(String text) {
return Padding(
padding: const EdgeInsets.only(right: 2),
child: Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 11,
letterSpacing: 0.9,
fontWeight: FontWeight.w600,
color: kBodyTextColor.withValues(alpha: 0.55),
),
),
);
}
Widget _filterChip({
required String label,
required bool selected,
required VoidCallback onTap,
int? count,
bool enabled = true,
}) {
final foreground = !enabled
? kBodyTextColor.withValues(alpha: 0.35)
: selected
? kWhite
: kBodyTextColor;
return InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
decoration: BoxDecoration(
color: selected ? kPrimaryColor : _kMuted,
border: Border.all(color: selected ? kPrimaryColor : _kLine),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(fontSize: 13, color: foreground)),
if (count != null) ...[
const SizedBox(width: 7),
Text(
_count(count),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: foreground.withValues(alpha: selected ? 0.8 : 0.6),
),
),
],
],
),
),
);
}
/// Deux phrases en langage naturel : ce qu'un gestionnaire retient s'il n'ouvre
/// l'écran que trois secondes. Règles simples, pas d'appel LLM.
List<String> _takeawaySentences(AppLocalizations l, StatsSummaryDTO stats) {
final sentences = <String>[];
final before = _previous?.totalSessions;
if (before != null && before > 0) {
final variation = (stats.totalSessions - before) / before * 100;
if (variation.abs() < 3) {
sentences.add(l.statsTakeawayStable);
} else if (variation > 0) {
sentences.add(l.statsTakeawayUp(_oneDecimal(variation)));
} else {
sentences.add(l.statsTakeawayDown(_oneDecimal(variation.abs())));
}
} else {
final days = math.max(1, _dailySeries(stats).length);
sentences.add(l.statsTakeawayVolume(
_count(stats.totalSessions),
_oneDecimal(stats.totalSessions / days),
));
}
final second = _secondTakeaway(l, stats);
if (second != null) sentences.add(second);
return sentences;
}
Widget _takeaway(AppLocalizations l, StatsSummaryDTO stats) {
final sentences = _takeawaySentences(l, stats);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15),
decoration: BoxDecoration(
color: _kMuted,
border: Border.all(color: _kLine),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final sentence in sentences)
Padding(
padding: EdgeInsets.only(top: sentence == sentences.first ? 0 : 6),
child: Text(
sentence,
style: TextStyle(fontSize: 14.5, height: 1.45, color: kBodyTextColor),
),
),
],
),
);
}
/// Multi-canal : la part du vocal, sinon le canal dominant. Mono-canal : il n'y
/// a pas de canaux à comparer, l'information intéressante est le contenu phare.
String? _secondTakeaway(AppLocalizations l, StatsSummaryDTO stats) {
if (_isMultiChannel && _selectedChannel == null) {
if (_voiceSessions(stats) > 0) {
return l.statsTakeawayVoice(_oneDecimal(_voiceShare(stats)));
}
final distribution = stats.appTypeDistribution.entries.toList();
if (distribution.isNotEmpty && stats.totalSessions > 0) {
final top = distribution.reduce((a, b) => a.value > b.value ? a : b);
final channel = AppType.values.firstWhere(
(type) => type.name == top.key,
orElse: () => AppType.Mobile,
);
return l.statsTakeawayChannel(
_channelLabel(channel),
_oneDecimal(top.value / stats.totalSessions * 100),
);
}
return null;
}
final views = _totalViews(stats);
if (stats.topSections.isEmpty || views == 0) return null;
final top = stats.topSections.first;
return l.statsTakeawayContent(
_plainTitle(top.sectionTitle, top.sectionId),
_oneDecimal(top.views / views * 100),
);
}
List<_Kpi> _kpis(AppLocalizations l, StatsSummaryDTO stats) {
final previous = _previous;
return [
_Kpi(
l.statsKpiVisits,
_count(stats.totalSessions),
trend: _percentTrend(l, stats.totalSessions, previous?.totalSessions),
),
_Kpi(
l.statsKpiAvgDuration,
_formatDuration(stats.avgVisitDurationSeconds),
trend: _durationTrend(
l,
stats.avgVisitDurationSeconds,
previous?.avgVisitDurationSeconds,
),
),
_Kpi(
l.statsKpiContentsPerVisit,
_oneDecimal(_contentsPerVisit(stats)),
trend: _percentTrend(
l,
_contentsPerVisit(stats),
previous == null ? null : _contentsPerVisit(previous),
),
),
if (_channels.contains(AppType.Voice))
_Kpi(
l.statsKpiVoiceShare,
_oneDecimal(_voiceShare(stats)),
unit: ' %',
trend: _percentTrend(
l,
_voiceShare(stats),
previous == null ? null : _voiceShare(previous),
),
)
else
_Kpi(
l.statsKpiTotalViews,
_count(_totalViews(stats)),
trend: _percentTrend(
l,
_totalViews(stats),
previous == null ? null : _totalViews(previous),
),
),
];
}
Widget _kpiRow(AppLocalizations l, StatsSummaryDTO stats, bool wide) {
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: wide ? 4 : 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: wide ? 2.0 : 2.4,
children: [
for (final kpi in _kpis(l, stats))
_kpiCard(
label: kpi.label,
value: _valueText(kpi.value, unit: kpi.unit),
trend: kpi.trend,
),
],
);
}
Widget _kpiCard({required String label, required Widget value, _Trend? trend}) {
return Container(
padding: const EdgeInsets.fromLTRB(17, 15, 17, 16),
decoration: BoxDecoration(
color: kWhite,
border: Border.all(color: _kLine),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5,
letterSpacing: 0.6,
color: kBodyTextColor.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
value,
if (trend != null) ...[
const SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(trend.icon, size: 13, color: trend.color),
const SizedBox(width: 4),
Flexible(
child: Text(
trend.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: trend.color),
),
),
],
),
],
],
),
);
}
Widget _valueText(String value, {String? unit}) {
return Text.rich(
TextSpan(
text: value,
children: unit == null
? null
: [
TextSpan(
text: unit,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: kBodyTextColor,
),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 27, fontWeight: FontWeight.w700, color: kPrimaryColor),
);
}
_Trend? _percentTrend(AppLocalizations l, num value, num? previous) {
if (previous == null || previous == 0) return null;
final variation = (value - previous) / previous * 100;
if (variation.abs() < 3) {
return _Trend(Icons.remove, l.statsTrendStable, kBodyTextColor.withValues(alpha: 0.6));
}
final up = variation > 0;
return _Trend(
up ? Icons.arrow_upward : Icons.arrow_downward,
'${_oneDecimal(variation.abs())} % ${l.statsVsPrevious}',
up ? _kUp : _kDown,
);
}
_Trend? _durationTrend(AppLocalizations l, int seconds, int? previous) {
if (previous == null || previous == 0) return null;
final delta = seconds - previous;
if (delta.abs() < 5) {
return _Trend(Icons.remove, l.statsTrendStable, kBodyTextColor.withValues(alpha: 0.6));
}
final up = delta > 0;
return _Trend(
up ? Icons.arrow_upward : Icons.arrow_downward,
_formatDuration(delta.abs()),
up ? _kUp : _kDown,
);
}
Widget _trendCard(AppLocalizations l, StatsSummaryDTO stats) {
final series = _dailySeries(stats);
if (series.isEmpty) return const SizedBox();
final peakIndex = _peakIndex(series);
final peak = series[peakIndex];
final maxY = _roundedAxisMax(peak.visits);
final showWeekends = series.length <= _kMaxDaysForWeekendBands;
final labelInterval = math.max(1, (series.length / 6).ceil()).toDouble();
return _card(
title: l.statsVisitsByDay,
subtitle: l.statsVisitsByDaySub,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 220,
child: LineChart(
LineChartData(
minX: 0,
maxX: (series.length - 1).toDouble(),
minY: 0,
maxY: maxY,
clipData: const FlClipData.all(),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: maxY / 2,
getDrawingHorizontalLine: (_) =>
const FlLine(color: _kLine, strokeWidth: 1),
),
rangeAnnotations: RangeAnnotations(
verticalRangeAnnotations: showWeekends
? [
for (var i = 0; i < series.length; i++)
if (series[i].isWeekend)
VerticalRangeAnnotation(
x1: i - 0.5,
x2: i + 0.5,
color: _kTrack.withValues(alpha: 0.7),
),
]
: const [],
),
titlesData: FlTitlesData(
rightTitles: const AxisTitles(),
topTitles: const AxisTitles(),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: maxY / 2,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
_count(value.round()),
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 11,
color: kBodyTextColor.withValues(alpha: 0.6),
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 26,
interval: labelInterval,
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= series.length) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
_shortDate(series[index].date),
style: TextStyle(
fontSize: 11,
color: kBodyTextColor.withValues(alpha: 0.6),
),
),
);
},
),
),
),
borderData: FlBorderData(show: false),
lineTouchData: LineTouchData(
getTouchedSpotIndicator: (barData, indexes) => indexes
.map((_) => TouchedSpotIndicatorData(
FlLine(color: kPrimaryColor.withValues(alpha: 0.35), strokeWidth: 1),
FlDotData(
getDotPainter: (spot, percent, bar, index) =>
FlDotCirclePainter(
radius: 4,
color: kPrimaryColor,
strokeWidth: 2,
strokeColor: kWhite,
),
),
))
.toList(),
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => kPrimaryColor,
getTooltipItems: (spots) => spots.map((spot) {
final point = series[spot.x.round()];
return LineTooltipItem(
'${_shortDate(point.date)}\n${_count(point.visits)}',
const TextStyle(color: kWhite, fontSize: 12, fontWeight: FontWeight.w600),
);
}).toList(),
),
),
lineBarsData: [
LineChartBarData(
spots: [
for (var i = 0; i < series.length; i++)
FlSpot(i.toDouble(), series[i].visits.toDouble()),
],
color: kPrimaryColor,
barWidth: 2,
isCurved: false,
dotData: FlDotData(
show: true,
checkToShowDot: (spot, bar) => spot.x.round() == peakIndex,
getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(
radius: 5,
color: kWhite,
strokeWidth: 2.5,
strokeColor: kPrimaryColor,
),
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
kPrimaryColor.withValues(alpha: 0.28),
kPrimaryColor.withValues(alpha: 0.0),
],
),
),
),
],
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (showWeekends)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 13,
height: 11,
decoration: BoxDecoration(
color: _kTrack,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Text(l.statsWeekends, style: _captionStyle),
],
),
if (peak.visits > 0)
Text(
l.statsPeakDay(_shortDate(peak.date), _count(peak.visits)),
style: _captionStyle,
),
],
),
],
),
);
}
/// Le rapport reprend l'identité du premier canal actif, l'app mobile en
/// priorité : `Instance` ne porte ni logo ni couleur, seuls les
/// `ApplicationInstance` en ont.
ApplicationInstanceDTO? get _brandingSource {
final apps = _ctx.instanceDTO?.applicationInstanceDTOs ?? const [];
if (apps.isEmpty) return null;
for (final app in apps) {
if (app.appType == AppType.Mobile) return app;
}
return apps.first;
}
int _brandArgb() {
final hex = (_brandingSource?.primaryColor ?? '').replaceAll('#', '').trim();
if (hex.length == 6) {
final parsed = int.tryParse('FF$hex', radix: 16);
if (parsed != null) return parsed;
}
return 0xFF264863;
}
Future<void> _exportReport(AppLocalizations l, StatsSummaryDTO stats) async {
setState(() => _exporting = true);
try {
final series = _dailySeries(stats);
final peak = series.isEmpty ? null : series[_peakIndex(series)];
final interval = math.max(1, (series.length / 6).ceil());
final channelBars = _channelBars(stats);
final languageBars = _languageBars(stats);
final instanceName = _ctx.instanceDTO?.name ?? '';
final data = StatisticsReportData(
instanceName: instanceName,
title: l.statsReportTitle,
periodLabel: l.statsPeriodRange(_shortDate(_from), _shortDate(_to)),
takeaways: _takeawaySentences(l, stats),
kpis: [
for (final kpi in _kpis(l, stats))
ReportKpi(kpi.label, '${kpi.value}${kpi.unit ?? ''}', kpi.trend?.label),
],
chartTitle: l.statsVisitsByDay,
chartSubtitle: l.statsTopContentsSub,
series: [
for (final point in series) ReportDayPoint(_shortDate(point.date), point.visits),
],
axisLabels: [
for (var i = 0; i < series.length; i += interval) i,
],
peakLabel: peak == null || peak.visits == 0
? null
: l.statsPeakDay(_shortDate(peak.date), _count(peak.visits)),
barGroups: [
if (stats.topSections.isNotEmpty)
_reportBarGroup(l.statsTopContents, l.statsTopContentsSub, _contentBars(stats)),
if (channelBars.isNotEmpty)
_reportBarGroup(l.statsChannels, l.statsChannelsSub, channelBars),
if (languageBars.isNotEmpty)
_reportBarGroup(l.statsLanguages, l.statsLanguagesSub, languageBars),
],
tables: [
for (final table in _advancedTables(l, stats))
ReportTable(table.title, table.headers, table.rows),
],
generatedLabel: l.statsReportGenerated(_shortDate(DateTime.now())),
brandArgb: _brandArgb(),
logo: await fetchReportLogo(_brandingSource?.mainImageUrl),
);
PDFHelper.downloadBytes(
await buildStatisticsReport(data),
_reportFileName(l, instanceName),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.statsReportError), backgroundColor: Colors.redAccent),
);
} finally {
if (mounted) setState(() => _exporting = false);
}
}
ReportBarGroup _reportBarGroup(String title, String subtitle, List<_Bar> bars) {
final max = bars.map((bar) => bar.value).reduce(math.max);
return ReportBarGroup(title, subtitle, [
for (final bar in bars)
ReportBar(bar.label, _count(bar.value), max == 0 ? 0 : bar.value / max),
]);
}
String _reportFileName(AppLocalizations l, String instanceName) {
final date = '${_to.year}-${_to.month.toString().padLeft(2, '0')}-'
'${_to.day.toString().padLeft(2, '0')}';
final slug = '$instanceName ${l.statsAttendanceTitle} $date'
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), '-')
.replaceAll(RegExp(r'^-+|-+$'), '');
return slug.isEmpty ? 'rapport' : slug;
}
int _peakIndex(List<_DayPoint> series) {
var index = 0;
for (var i = 1; i < series.length; i++) {
if (series[i].visits > series[index].visits) index = i;
}
return index;
}
/// Arrondit le haut de l'axe pour que les deux graduations tombent sur des
/// nombres ronds (0 / moitié / max).
double _roundedAxisMax(int peak) {
if (peak <= 4) return 4;
final magnitude = math.pow(10, (math.log(peak) / math.ln10).floor()).toDouble();
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;
}
/// Contenus à gauche, canaux et langues à droite. Barres horizontales partout,
/// une seule teinte : toutes mesurent un nombre de visites, c'est la longueur
/// qui porte l'information.
List<_Bar> _contentBars(StatsSummaryDTO stats) => [
for (final section in stats.topSections.take(8))
_Bar(_plainTitle(section.sectionTitle, section.sectionId), section.views),
];
/// Un graphe à une seule barre à 100 % ne dit rien : ni en mono-canal, ni
/// quand l'écran est déjà filtré sur un canal.
List<_Bar> _channelBars(StatsSummaryDTO stats) {
if (!_isMultiChannel || _selectedChannel != null) return const [];
return [
for (final channel in _channels)
if ((stats.appTypeDistribution[channel.name] ?? 0) > 0)
_Bar(_channelLabel(channel), stats.appTypeDistribution[channel.name]!),
];
}
List<_Bar> _languageBars(StatsSummaryDTO stats) => [
for (final entry in _sortedEntries(stats.languageDistribution))
_Bar(entry.key.toUpperCase(), entry.value),
];
Widget _distributionRow(AppLocalizations l, StatsSummaryDTO stats, bool wide) {
final contents = _card(
title: l.statsTopContents,
subtitle: l.statsTopContentsSub,
child: _bars(_contentBars(stats)),
);
final channelBars = _channelBars(stats);
final languageBars = _languageBars(stats);
final side = <Widget>[
if (channelBars.isNotEmpty)
_card(
title: l.statsChannels,
subtitle: l.statsChannelsSub,
child: _bars(channelBars),
),
if (languageBars.isNotEmpty)
_card(
title: l.statsLanguages,
subtitle: l.statsLanguagesSub,
child: _bars(languageBars),
),
];
if (stats.topSections.isEmpty && side.isEmpty) return const SizedBox();
final sideColumn = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final card in side)
Padding(
padding: EdgeInsets.only(bottom: card == side.last ? 0 : 16),
child: card,
),
],
);
if (!wide) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (stats.topSections.isNotEmpty) ...[contents, const SizedBox(height: 16)],
sideColumn,
],
);
}
if (side.isEmpty) return contents;
if (stats.topSections.isEmpty) return sideColumn;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 135, child: contents),
const SizedBox(width: 16),
Expanded(flex: 100, child: sideColumn),
],
);
}
List<MapEntry<String, int>> _sortedEntries(Map<String, int> data) {
final entries = data.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
return entries;
}
Widget _bars(List<_Bar> bars) {
if (bars.isEmpty) return const SizedBox();
final max = bars.map((bar) => bar.value).reduce(math.max);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final bar in bars)
Padding(
padding: EdgeInsets.only(bottom: bar == bars.last ? 0 : 11),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
bar.label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13.5, color: kBodyTextColor),
),
),
const SizedBox(width: 10),
Text(
_count(bar.value),
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: kBodyTextColor.withValues(alpha: 0.8),
),
),
],
),
const SizedBox(height: 5),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: max == 0 ? 0 : bar.value / max,
minHeight: 9,
backgroundColor: _kTrack,
color: kPrimaryColor,
),
),
],
),
),
],
);
}
List<_TableData> _advancedTables(AppLocalizations l, StatsSummaryDTO stats) {
if (!_hasAdvancedStats) return const [];
return [
if (stats.topPois.isNotEmpty)
_TableData(l.statsTopPOI, [l.statsPOI, l.statsTaps], [
for (final poi in stats.topPois)
[_plainTitle(poi.title, poi.geoPointId?.toString()), _count(poi.taps)],
]),
if (stats.topAgendaEvents.isNotEmpty)
_TableData(l.statsTopAgenda, [l.statsEvent, l.statsTaps], [
for (final event in stats.topAgendaEvents)
[_plainTitle(event.eventTitle, event.eventId), _count(event.taps)],
]),
if (stats.quizStats.isNotEmpty)
_TableData(l.statsQuiz, [l.statsSection, l.statsAvgScore, l.statsCompletions], [
for (final quiz in stats.quizStats)
[
_plainTitle(quiz.sectionTitle, quiz.sectionId),
'${_oneDecimal(quiz.avgScore)} / ${quiz.totalQuestions}',
_count(quiz.completions),
],
]),
if (stats.gameStats.isNotEmpty)
_TableData(l.statsGames, [l.statsGameType, l.statsCompletions, l.statsAvgDuration], [
for (final game in stats.gameStats)
[
game.gameType ?? '',
_count(game.completions),
_formatDuration(game.avgDurationSeconds),
],
]),
if (stats.topArticles.isNotEmpty)
_TableData(l.statsArticles, [l.statsSection, l.statsReadings], [
for (final article in stats.topArticles)
[article.sectionId ?? '', _count(article.reads)],
]),
if (stats.topMenuItems.isNotEmpty)
_TableData(l.statsMenuTitle, [l.statsMenuItem, l.statsTaps], [
for (final item in stats.topMenuItems)
[_plainTitle(item.menuItemTitle, item.targetSectionId), _count(item.taps)],
]),
if (stats.qrScans.totalScans > 0)
_TableData(l.statsQrScans, [l.statsTotal, l.statsViews], [
[l.statsTotal, _count(stats.qrScans.totalScans)],
[l.statsValid, _count(stats.qrScans.validScans)],
[l.statsInvalid, _count(stats.qrScans.invalidScans)],
]),
];
}
Widget _advancedSection(AppLocalizations l, StatsSummaryDTO stats) {
if (!_hasAdvancedStats) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _lockedCard(l),
);
}
final tables = _advancedTables(l, stats);
if (tables.isEmpty) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Wrap(
spacing: 16,
runSpacing: 16,
children: [
for (final table in tables)
SizedBox(width: 340, child: _tableCard(table.title, table.headers, table.rows)),
],
),
);
}
/// Le rapport se montre comme le document qu'on va envoyer, pas comme un bouton :
/// c'est ce qui transforme l'écran en livrable pour un dossier de subside.
Widget _reportCard(AppLocalizations l, StatsSummaryDTO stats) {
final bullets = [
l.statsReportItemAttendance,
l.statsReportItemContents,
l.statsReportItemChannels,
if (_hasAdvancedStats) l.statsReportItemAdvanced,
];
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: kWhite,
border: Border.all(color: kPrimaryColor),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_documentPreview(),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.statsReportTitle,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: kPrimaryColor),
),
const SizedBox(height: 5),
Text(
l.statsReportBody,
style: TextStyle(fontSize: 13.5, color: kBodyTextColor),
),
const SizedBox(height: 12),
for (final bullet in bullets)
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('', style: _captionStyle),
Expanded(child: Text(bullet, style: _captionStyle)),
],
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _exporting ? null : () => _exportReport(l, stats),
icon: _exporting
? const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(strokeWidth: 2, color: kWhite),
)
: const Icon(Icons.download_outlined, size: 17),
label: Text(l.statsReportDownload),
style: FilledButton.styleFrom(backgroundColor: kPrimaryColor),
),
],
),
),
],
),
);
}
Widget _documentPreview() {
Widget line(double widthFactor) => FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: widthFactor,
child: Container(
height: 4,
margin: const EdgeInsets.only(bottom: 5),
decoration: BoxDecoration(
color: _kTrack,
borderRadius: BorderRadius.circular(2),
),
),
);
return Container(
width: 108,
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 13),
decoration: BoxDecoration(
color: kWhite,
border: Border.all(color: _kLine),
borderRadius: BorderRadius.circular(4),
boxShadow: const [
BoxShadow(color: _kTrack, blurRadius: 8, offset: Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 16,
margin: const EdgeInsets.only(bottom: 9),
decoration: BoxDecoration(
color: kPrimaryColor,
borderRadius: BorderRadius.circular(2),
),
),
line(0.84),
line(1),
line(0.62),
Container(
height: 26,
margin: const EdgeInsets.only(top: 4, bottom: 7),
decoration: BoxDecoration(
color: kPrimaryColor.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(3),
),
),
line(1),
line(0.84),
line(0.62),
],
),
);
}
Widget _lockedCard(AppLocalizations l) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _kMuted,
border: Border.all(color: _kLine),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.lock_outline, color: kBodyTextColor.withValues(alpha: 0.5), size: 26),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.statsAdvancedTitle,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kPrimaryColor),
),
const SizedBox(height: 4),
Text(l.statsAdvancedBody, style: TextStyle(fontSize: 13, color: kBodyTextColor)),
],
),
),
],
),
);
}
Widget _card({required String title, String? subtitle, required Widget child}) {
return Container(
padding: const EdgeInsets.fromLTRB(19, 18, 19, 20),
decoration: BoxDecoration(
color: kWhite,
border: Border.all(color: _kLine),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.w700, color: kPrimaryColor),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(subtitle, style: _captionStyle),
],
const SizedBox(height: 16),
child,
],
),
);
}
Widget _tableCard(String title, List<String> headers, List<List<String>> rows) {
return _card(
title: title,
child: Table(
columnWidths: const {0: FlexColumnWidth(2), 1: FlexColumnWidth(1)},
children: [
TableRow(
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: _kLine))),
children: headers
.map((header) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
header,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: kBodyTextColor.withValues(alpha: 0.7),
),
),
))
.toList(),
),
for (final row in rows)
TableRow(
children: row
.map((cell) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
cell,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: kBodyTextColor),
),
))
.toList(),
),
],
),
);
}
Widget _emptyMessage(IconData icon, String title, String? detail) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 52, color: kBodyTextColor.withValues(alpha: 0.4)),
const SizedBox(height: 16),
Text(title, style: TextStyle(fontSize: 15, color: kBodyTextColor)),
if (detail != null) ...[
const SizedBox(height: 8),
Text(
detail,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: kBodyTextColor.withValues(alpha: 0.6)),
),
],
],
),
);
}
TextStyle get _captionStyle =>
TextStyle(fontSize: 12.5, color: kBodyTextColor.withValues(alpha: 0.65));
}
class _DayPoint {
const _DayPoint(this.date, this.visits);
final DateTime date;
final int visits;
bool get isWeekend =>
date.weekday == DateTime.saturday || date.weekday == DateTime.sunday;
}
class _Bar {
const _Bar(this.label, this.value);
final String label;
final int value;
}
class _Kpi {
const _Kpi(this.label, this.value, {this.unit, this.trend});
final String label;
final String value;
final String? unit;
final _Trend? trend;
}
class _TableData {
const _TableData(this.title, this.headers, this.rows);
final String title;
final List<String> headers;
final List<List<String>> rows;
}
class _Trend {
const _Trend(this.icon, this.label, this.color);
final IconData icon;
final String label;
final Color color;
}