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 createState() => _StatisticsScreenState(); } class _StatisticsScreenState extends State { 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(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 get _channels { final declared = (_ctx.instanceDTO?.applicationInstanceDTOs ?? const []) .map((app) => app.appType) .whereType(); 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 _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([ 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 _takeawaySentences(AppLocalizations l, StatsSummaryDTO stats) { final sentences = []; 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 _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 = [ 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> _sortedEntries(Map 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 headers, List> 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 headers; final List> rows; } class _Trend { const _Trend(this.icon, this.label, this.color); final IconData icon; final String label; final Color color; }