manager-app/lib/Screens/Statistics/statistics_screen.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(),
],
),
],
),
),
);
}
}