625 lines
24 KiB
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(),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|