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.
1482 lines
49 KiB
Dart
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;
|
|
}
|