manager-app/lib/Screens/Statistics/statistics_screen.dart

625 lines
24 KiB
Dart

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_api_new/api.dart';
import 'package:provider/provider.dart';
class StatisticsScreen extends StatefulWidget {
const StatisticsScreen({Key? key}) : super(key: key);
@override
_StatisticsScreenState createState() => _StatisticsScreenState();
}
class _StatisticsScreenState extends State<StatisticsScreen> {
int _selectedDays = 30;
AppType? _selectedAppType; // null = "Tous"
Future<StatsSummaryDTO?>? _future;
late ManagerAppContext _managerAppContext;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
void _load() {
final days = _selectedDays;
final appType = _selectedAppType;
setState(() {
_future = _managerAppContext.clientAPI!.statsApi!.statsGetSummary(
_managerAppContext.instanceId!,
from: DateTime.now().subtract(Duration(days: days)),
to: DateTime.now(),
appType: appType?.name,
);
});
}
String _formatDuration(int seconds) {
if (seconds < 60) return '${seconds}s';
final m = seconds ~/ 60;
final s = seconds % 60;
return '${m}m${s > 0 ? ' ${s}s' : ''}';
}
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
_managerAppContext = appContext.getContext() as ManagerAppContext;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 16),
Expanded(
child: FutureBuilder<StatsSummaryDTO?>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CommonLoader();
}
if (snapshot.hasError || snapshot.data == null) {
return Center(child: Text(AppLocalizations.of(context)!.statsLoadError, style: TextStyle(color: kBodyTextColor)));
}
final stats = snapshot.data!;
return _buildDashboard(context, stats);
},
),
),
],
),
);
}
bool get _hasAdvancedStats =>
_managerAppContext.instanceDTO?.hasAdvancedStats ?? false;
int get _statsHistoryDays =>
_managerAppContext.instanceDTO?.statsHistoryDays ?? 30;
Widget _buildHeader(BuildContext context) {
final l = AppLocalizations.of(context)!;
final historyDays = _statsHistoryDays;
final maxDays = historyDays == 0 ? 999 : historyDays;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l.statisticsTitle, style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600, color: kPrimaryColor)),
SegmentedButton<int>(
style: SegmentedButton.styleFrom(
selectedBackgroundColor: kPrimaryColor,
selectedForegroundColor: kWhite,
),
segments: [
const ButtonSegment(value: 7, label: Text('7j')),
const ButtonSegment(value: 30, label: Text('30j')),
ButtonSegment(value: 90, label: Text('90j'), enabled: maxDays >= 90),
],
selected: {_selectedDays},
onSelectionChanged: (s) {
_selectedDays = s.first;
_load();
},
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: Text(l.statsAll),
selected: _selectedAppType == null,
selectedColor: kPrimaryColor,
labelStyle: TextStyle(color: _selectedAppType == null ? kWhite : kBodyTextColor),
onSelected: (_) { _selectedAppType = null; _load(); },
),
...AppType.values.map((type) => ChoiceChip(
label: Text(type.name),
selected: _selectedAppType == type,
selectedColor: kPrimaryColor,
labelStyle: TextStyle(color: _selectedAppType == type ? kWhite : kBodyTextColor),
onSelected: (_) { _selectedAppType = type; _load(); },
)),
],
),
],
);
}
Widget _buildDashboard(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
if (stats.totalSessions == 0) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bar_chart_outlined, size: 56, color: kBodyTextColor.withValues(alpha: 0.4)),
const SizedBox(height: 16),
Text(
l.statsNoData,
style: TextStyle(fontSize: 15, color: kBodyTextColor),
),
if (_selectedAppType != null) ...[
const SizedBox(height: 8),
Text(
l.statsNoDataForType(_selectedAppType!.name),
style: TextStyle(fontSize: 13, color: kBodyTextColor.withValues(alpha: 0.6)),
),
],
],
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildKpiRow(context, stats),
const SizedBox(height: 20),
_buildLineChart(context, stats),
const SizedBox(height: 20),
_buildChartsRow(context, stats),
const SizedBox(height: 20),
_buildTablesRow(context, stats),
],
),
);
}
Widget _buildKpiRow(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
final topAppType = stats.appTypeDistribution.isNotEmpty
? stats.appTypeDistribution.entries.reduce((a, b) => a.value > b.value ? a : b)
: null;
final topLang = stats.languageDistribution.isNotEmpty
? stats.languageDistribution.entries.reduce((a, b) => a.value > b.value ? a : b)
: null;
return Row(
children: [
_kpiCard(l.statsSessions, '${stats.totalSessions}', Icons.people_outline),
const SizedBox(width: 12),
_kpiCard(l.statsAvgDuration, _formatDuration(stats.avgVisitDurationSeconds), Icons.timer_outlined),
const SizedBox(width: 12),
_kpiCard(l.statsTopApp, topAppType?.key ?? '', Icons.phone_iphone),
const SizedBox(width: 12),
_kpiCard(l.statsTopLang, topLang?.key.toUpperCase() ?? '', Icons.language),
],
);
}
Widget _kpiCard(String label, String value, IconData icon) {
return Expanded(
child: Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: kPrimaryColor, size: 22),
const SizedBox(height: 8),
Text(value, style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, color: kPrimaryColor)),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 13, color: kBodyTextColor)),
],
),
),
),
);
}
Widget _buildLineChart(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
if (stats.visitsByDay.isEmpty) return const SizedBox();
final spots = <FlSpot>[];
final mobileSpots = <FlSpot>[];
final tabletSpots = <FlSpot>[];
for (var i = 0; i < stats.visitsByDay.length; i++) {
final d = stats.visitsByDay[i];
spots.add(FlSpot(i.toDouble(), d.total.toDouble()));
mobileSpots.add(FlSpot(i.toDouble(), d.mobile.toDouble()));
tabletSpots.add(FlSpot(i.toDouble(), d.tablet.toDouble()));
}
final mobileHasData = mobileSpots.any((s) => s.y > 0);
final tabletHasData = tabletSpots.any((s) => s.y > 0);
final showBreakdown = _selectedAppType == null && (mobileHasData || tabletHasData);
return Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.statsVisitsByDay, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 4),
if (showBreakdown) Row(children: [
if (mobileHasData) ...[_legendDot(kPrimaryColor), const SizedBox(width: 4), Text('Mobile', style: TextStyle(fontSize: 12, color: kBodyTextColor)), const SizedBox(width: 12)],
if (tabletHasData) ...[_legendDot(kSecond), const SizedBox(width: 4), Text('Tablet', style: TextStyle(fontSize: 12, color: kBodyTextColor))],
]),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: LineChart(LineChartData(
minY: 0,
gridData: FlGridData(show: true, drawVerticalLine: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, reservedSize: 32)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
interval: (stats.visitsByDay.length / 5).ceilToDouble().clamp(1, double.infinity),
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx < 0 || idx >= stats.visitsByDay.length) return const SizedBox();
final date = stats.visitsByDay[idx].date ?? '';
return Text(date.length >= 5 ? date.substring(5) : date, style: const TextStyle(fontSize: 10));
},
)),
),
borderData: FlBorderData(show: false),
lineBarsData: showBreakdown
? [
if (mobileHasData) _lineBar(mobileSpots, kPrimaryColor),
if (tabletHasData) _lineBar(tabletSpots, kSecond),
]
: [_lineBar(spots, kPrimaryColor)],
)),
),
],
),
),
);
}
Widget _legendDot(Color color) {
return Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle));
}
LineChartBarData _lineBar(List<FlSpot> spots, Color color) {
return LineChartBarData(
spots: spots,
isCurved: true,
color: color,
barWidth: 2,
dotData: FlDotData(show: spots.length <= 1),
belowBarData: BarAreaData(show: true, color: color.withValues(alpha: 0.1)),
);
}
Widget _buildChartsRow(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
final showAppTypeDonut = _selectedAppType == null;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 2, child: _buildTopSectionsChart(context, stats)),
const SizedBox(width: 12),
if (showAppTypeDonut) ...[
Expanded(child: _buildDonut(stats.appTypeDistribution, l.statsApps)),
const SizedBox(width: 12),
],
Expanded(child: _buildDonut(stats.languageDistribution, l.statsLanguages)),
],
);
}
Widget _buildTopSectionsChart(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
if (stats.topSections.isEmpty) return const SizedBox();
final sections = stats.topSections.take(8).toList();
final maxViews = sections.map((s) => s.views).reduce((a, b) => a > b ? a : b);
return Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.statsTopSections, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 16),
SizedBox(
height: 240,
child: BarChart(BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxViews.toDouble() * 1.2,
barTouchData: BarTouchData(enabled: true),
titlesData: FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, reservedSize: 32)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx < 0 || idx >= sections.length) return const SizedBox();
final raw = sections[idx].sectionTitle ?? sections[idx].sectionId ?? '';
final title = raw.replaceAll(RegExp(r'<[^>]*>'), '').trim();
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
title.length > 8 ? '${title.substring(0, 8)}' : title,
style: const TextStyle(fontSize: 10),
),
);
},
)),
),
borderData: FlBorderData(show: false),
gridData: FlGridData(drawVerticalLine: false),
barGroups: List.generate(sections.length, (i) => BarChartGroupData(
x: i,
barRods: [BarChartRodData(
toY: sections[i].views.toDouble(),
color: kPrimaryColor,
width: 16,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
)],
)),
)),
),
],
),
),
);
}
static const _chartColors = [
Color(0xFF264863),
Color(0xFF4A8FAD),
Color(0xFF87C4D8),
Color(0xFFC2C9D6),
Color(0xFF8BA7B8),
Color(0xFF1A3347),
];
Widget _buildDonut(Map<String, int> data, String title) {
if (data.isEmpty) return const SizedBox();
final total = data.values.fold(0, (a, b) => a + b);
final entries = data.entries.toList();
return Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 16),
SizedBox(
height: 160,
child: PieChart(PieChartData(
centerSpaceRadius: 45,
sectionsSpace: 2,
sections: List.generate(entries.length, (i) {
final pct = total > 0 ? (entries[i].value / total * 100).round() : 0;
return PieChartSectionData(
value: entries[i].value.toDouble(),
color: _chartColors[i % _chartColors.length],
title: '$pct%',
titleStyle: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600),
radius: 40,
);
}),
)),
),
const SizedBox(height: 12),
...List.generate(entries.length, (i) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(children: [
Container(width: 10, height: 10, decoration: BoxDecoration(
color: _chartColors[i % _chartColors.length], shape: BoxShape.circle)),
const SizedBox(width: 6),
Expanded(child: Text(entries[i].key, style: TextStyle(fontSize: 12, color: kBodyTextColor), overflow: TextOverflow.ellipsis)),
Text('${entries[i].value}', style: TextStyle(fontSize: 12, color: kBodyTextColor)),
]),
)),
],
),
),
);
}
Widget _buildTablesRow(BuildContext context, StatsSummaryDTO stats) {
if (!_hasAdvancedStats) {
return _buildPremiumLockedCard(context);
}
final tables = <Widget>[
if (stats.topPois.isNotEmpty) Expanded(child: _buildPoiTable(context, stats)),
if (stats.topAgendaEvents.isNotEmpty) Expanded(child: _buildAgendaTable(context, stats)),
if (stats.quizStats.isNotEmpty) Expanded(child: _buildQuizTable(context, stats)),
if (stats.gameStats.isNotEmpty) Expanded(child: _buildGameTable(context, stats)),
if (stats.topArticles.isNotEmpty) Expanded(child: _buildArticleTable(context, stats)),
if (stats.topMenuItems.isNotEmpty) Expanded(child: _buildMenuTable(context, stats)),
if (stats.qrScans.totalScans > 0) Expanded(child: _buildQrCard(context, stats)),
];
if (tables.isEmpty) return const SizedBox();
final spaced = <Widget>[];
for (var i = 0; i < tables.length; i++) {
spaced.add(tables[i]);
if (i < tables.length - 1) spaced.add(const SizedBox(width: 12));
}
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: spaced);
}
Widget _buildPremiumLockedCard(BuildContext context) {
return Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Row(
children: [
Icon(Icons.lock_outline, color: kBodyTextColor.withValues(alpha: 0.5), size: 28),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Statistiques avancées', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 4),
Text(
'Les stats détaillées (POI, quiz, jeux, articles, QR…) sont disponibles avec le plan Premium.',
style: TextStyle(fontSize: 13, color: kBodyTextColor),
),
],
),
),
],
),
),
);
}
Widget _buildPoiTable(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
return _tableCard(l.statsTopPOI, [l.statsPOI, l.statsTaps], stats.topPois.map((p) => [
p.title ?? p.geoPointId?.toString() ?? '',
'${p.taps}',
]).toList());
}
Widget _buildAgendaTable(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
return _tableCard(l.statsTopAgenda, [l.statsEvent, l.statsTaps], stats.topAgendaEvents.map((e) => [
e.eventTitle ?? e.eventId ?? '',
'${e.taps}',
]).toList());
}
Widget _buildQuizTable(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
return _tableCard(l.statsQuiz, [l.statsSection, l.statsAvgScore, l.statsCompletions], stats.quizStats.map((q) => [
q.sectionTitle ?? q.sectionId ?? '',
'${q.avgScore.toStringAsFixed(1)} / ${q.totalQuestions}',
'${q.completions}',
]).toList());
}
Widget _buildGameTable(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
return _tableCard(l.statsGames, [l.statsGameType, l.statsCompletions, l.statsAvgDuration], stats.gameStats.map((g) => [
g.gameType ?? '',
'${g.completions}',
_formatDuration(g.avgDurationSeconds),
]).toList());
}
Widget _buildArticleTable(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
return _tableCard(l.statsArticles, [l.statsSection, l.statsReadings], stats.topArticles.map((a) => [
a.sectionId ?? '',
'${a.reads}',
]).toList());
}
Widget _buildMenuTable(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
return _tableCard(l.statsMenuTitle, [l.statsMenuItem, l.statsTaps], stats.topMenuItems.map((m) => [
m.menuItemTitle ?? m.targetSectionId ?? '',
'${m.taps}',
]).toList());
}
Widget _buildQrCard(BuildContext context, StatsSummaryDTO stats) {
final l = AppLocalizations.of(context)!;
final qr = stats.qrScans;
return Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.statsQrScans, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 12),
_qrRow(Icons.qr_code_scanner, l.statsTotal, '${qr.totalScans}', kPrimaryColor),
const SizedBox(height: 8),
_qrRow(Icons.check_circle_outline, l.statsValid, '${qr.validScans}', Colors.green.shade600),
const SizedBox(height: 8),
_qrRow(Icons.cancel_outlined, l.statsInvalid, '${qr.invalidScans}', Colors.red.shade400),
],
),
),
);
}
Widget _qrRow(IconData icon, String label, String value, Color color) {
return Row(children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Expanded(child: Text(label, style: TextStyle(fontSize: 13, color: kBodyTextColor))),
Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: color)),
]);
}
Widget _tableCard(String title, List<String> headers, List<List<String>> rows) {
return Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 12),
Table(
columnWidths: const {0: FlexColumnWidth(2), 1: FlexColumnWidth(1)},
children: [
TableRow(
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: kSecond))),
children: headers.map((h) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(h, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: kBodyTextColor)),
)).toList(),
),
...rows.map((row) => TableRow(
children: row.map((cell) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(cell, style: TextStyle(fontSize: 13, color: kBodyTextColor), overflow: TextOverflow.ellipsis),
)).toList(),
)).toList(),
],
),
],
),
),
);
}
}