514 lines
19 KiB
Dart
514 lines
19 KiB
Dart
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:manager_app/Models/managerContext.dart';
|
|
import 'package:manager_app/app_context.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(),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: FutureBuilder<StatsSummaryDTO?>(
|
|
future: _future,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot.hasError || snapshot.data == null) {
|
|
return Center(child: Text('Impossible de charger les statistiques', style: TextStyle(color: kBodyTextColor)));
|
|
}
|
|
final stats = snapshot.data!;
|
|
return _buildDashboard(stats);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('Statistiques', 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')),
|
|
ButtonSegment(value: 30, label: Text('30j')),
|
|
ButtonSegment(value: 90, label: Text('90j')),
|
|
],
|
|
selected: {_selectedDays},
|
|
onSelectionChanged: (s) {
|
|
_selectedDays = s.first;
|
|
_load();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
ChoiceChip(
|
|
label: const Text('Tous'),
|
|
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(StatsSummaryDTO stats) {
|
|
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(
|
|
'Pas encore de données pour cette période',
|
|
style: TextStyle(fontSize: 15, color: kBodyTextColor),
|
|
),
|
|
if (_selectedAppType != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Aucun event reçu pour le type "${_selectedAppType!.name}"',
|
|
style: TextStyle(fontSize: 13, color: kBodyTextColor.withValues(alpha: 0.6)),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// KPI cards
|
|
_buildKpiRow(stats),
|
|
const SizedBox(height: 20),
|
|
// Line chart
|
|
_buildLineChart(stats),
|
|
const SizedBox(height: 20),
|
|
// Bar chart + Donut charts
|
|
_buildChartsRow(stats),
|
|
const SizedBox(height: 20),
|
|
// Bottom tables
|
|
_buildTablesRow(stats),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildKpiRow(StatsSummaryDTO stats) {
|
|
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('Sessions', '${stats.totalSessions}', Icons.people_outline),
|
|
const SizedBox(width: 12),
|
|
_kpiCard('Durée moy.', _formatDuration(stats.avgVisitDurationSeconds), Icons.timer_outlined),
|
|
const SizedBox(width: 12),
|
|
_kpiCard('App top', topAppType?.key ?? '—', Icons.phone_iphone),
|
|
const SizedBox(width: 12),
|
|
_kpiCard('Langue top', 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(StatsSummaryDTO stats) {
|
|
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 isFiltered = _selectedAppType != null;
|
|
|
|
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('Visites par jour', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)),
|
|
const SizedBox(height: 4),
|
|
if (!isFiltered) Row(children: [
|
|
_legendDot(kPrimaryColor), const SizedBox(width: 4), Text('Mobile', style: TextStyle(fontSize: 12, color: kBodyTextColor)),
|
|
const SizedBox(width: 12),
|
|
_legendDot(kSecond), const SizedBox(width: 4), Text('Tablet', style: TextStyle(fontSize: 12, color: kBodyTextColor)),
|
|
]),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
height: 200,
|
|
child: LineChart(LineChartData(
|
|
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: isFiltered
|
|
? [_lineBar(spots, kPrimaryColor)]
|
|
: [_lineBar(mobileSpots, kPrimaryColor), _lineBar(tabletSpots, kSecond)],
|
|
)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: false),
|
|
belowBarData: BarAreaData(show: true, color: color.withOpacity(0.1)),
|
|
);
|
|
}
|
|
|
|
Widget _buildChartsRow(StatsSummaryDTO stats) {
|
|
final showAppTypeDonut = _selectedAppType == null;
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(flex: 2, child: _buildTopSectionsChart(stats)),
|
|
const SizedBox(width: 12),
|
|
if (showAppTypeDonut) ...[
|
|
Expanded(child: _buildDonut(stats.appTypeDistribution, 'Apps')),
|
|
const SizedBox(width: 12),
|
|
],
|
|
Expanded(child: _buildDonut(stats.languageDistribution, 'Langues')),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTopSectionsChart(StatsSummaryDTO stats) {
|
|
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('Top sections', 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 title = sections[idx].sectionTitle ?? sections[idx].sectionId ?? '';
|
|
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(StatsSummaryDTO stats) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (stats.topPois.isNotEmpty) Expanded(child: _buildPoiTable(stats)),
|
|
if (stats.topPois.isNotEmpty && stats.topAgendaEvents.isNotEmpty) const SizedBox(width: 12),
|
|
if (stats.topAgendaEvents.isNotEmpty) Expanded(child: _buildAgendaTable(stats)),
|
|
if ((stats.topPois.isNotEmpty || stats.topAgendaEvents.isNotEmpty) && stats.quizStats.isNotEmpty) const SizedBox(width: 12),
|
|
if (stats.quizStats.isNotEmpty) Expanded(child: _buildQuizTable(stats)),
|
|
if ((stats.topPois.isNotEmpty || stats.topAgendaEvents.isNotEmpty || stats.quizStats.isNotEmpty) && stats.gameStats.isNotEmpty) const SizedBox(width: 12),
|
|
if (stats.gameStats.isNotEmpty) Expanded(child: _buildGameTable(stats)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildPoiTable(StatsSummaryDTO stats) {
|
|
return _tableCard('Top POI', ['POI', 'Taps'], stats.topPois.map((p) => [
|
|
p.title ?? p.geoPointId?.toString() ?? '—',
|
|
'${p.taps}',
|
|
]).toList());
|
|
}
|
|
|
|
Widget _buildAgendaTable(StatsSummaryDTO stats) {
|
|
return _tableCard('Top événements agenda', ['Événement', 'Taps'], stats.topAgendaEvents.map((e) => [
|
|
e.eventTitle ?? e.eventId ?? '—',
|
|
'${e.taps}',
|
|
]).toList());
|
|
}
|
|
|
|
Widget _buildQuizTable(StatsSummaryDTO stats) {
|
|
return _tableCard('Quiz', ['Section', 'Score moy', 'Complétions'], stats.quizStats.map((q) => [
|
|
q.sectionTitle ?? q.sectionId ?? '—',
|
|
'${q.avgScore.toStringAsFixed(1)} / ${q.totalQuestions}',
|
|
'${q.completions}',
|
|
]).toList());
|
|
}
|
|
|
|
Widget _buildGameTable(StatsSummaryDTO stats) {
|
|
return _tableCard('Jeux', ['Type', 'Complétions', 'Durée moy.'], stats.gameStats.map((g) => [
|
|
g.gameType ?? '—',
|
|
'${g.completions}',
|
|
_formatDuration(g.avgDurationSeconds),
|
|
]).toList());
|
|
}
|
|
|
|
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(),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|