fix: clarifie les libelles de stats et corrige le calcul de l etalement moyen

- score_card: "Pourcentage" -> "Reussite", ajoute "/10" sur la moyenne
- grouping_stats: "Diametre" -> "Etalement", direction du decalage en toutes lettres (Droite, Haut-Droite...) au lieu de D/G/H/B
- statistics: corrige le bug de priorite (... ?? 0 * 100) qui affichait un etalement moyen errone (~0.9% au lieu de ~90%)
- ajoute un bouton d aide reutilisable (MetricInfoButton) expliquant chaque metrique, dont la distinction Precision (distance) / Reussite (score)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
qguillaume
2026-06-16 10:26:54 +02:00
parent 600a6bef30
commit 1d8124c8d8
4 changed files with 206 additions and 29 deletions

View File

@@ -0,0 +1,76 @@
/// Bouton d'information (ⓘ) qui explique des métriques à l'utilisateur.
///
/// Affiche une petite icône cliquable ; au clic, une boîte de dialogue
/// détaille la signification de chaque statistique de la carte associée.
library;
import 'package:flutter/material.dart';
/// Explication d'une métrique : un libellé et sa description.
class MetricExplanation {
final String label;
final String description;
const MetricExplanation(this.label, this.description);
}
class MetricInfoButton extends StatelessWidget {
final String title;
final List<MetricExplanation> explanations;
const MetricInfoButton({
super.key,
required this.title,
required this.explanations,
});
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(Icons.info_outline, size: 18, color: Colors.grey[500]),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'À quoi ça correspond ?',
onPressed: () => _showInfo(context),
);
}
void _showInfo(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final e in explanations)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.label,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 2),
Text(e.description),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Compris'),
),
],
),
);
}
}

View File

@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../services/grouping_analyzer_service.dart'; import '../../../services/grouping_analyzer_service.dart';
class GroupingStats extends StatelessWidget { class GroupingStats extends StatelessWidget {
@@ -43,31 +44,64 @@ class GroupingStats extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const MetricInfoButton(
title: 'Groupement',
explanations: [
MetricExplanation(
'Étalement',
'Distance entre vos deux impacts les plus éloignés, '
'exprimée en % de la largeur de l\'image. Plus c\'est '
'bas, plus le groupement est serré.',
),
MetricExplanation(
'Dispersion',
'Régularité des impacts autour de leur centre commun '
'(écart-type). Plus c\'est bas, plus vos tirs sont '
'réguliers.',
),
MetricExplanation(
'Décalage',
'Direction du centre de votre groupement par rapport au '
'centre de la cible (ex. « Droite » = vos tirs sont '
'globalement décalés vers la droite).',
),
MetricExplanation(
'Étoiles',
'Qualité globale du groupement, de ★ (à améliorer) à '
'★★★★★ (excellent).',
),
],
),
const Spacer(), const Spacer(),
_buildQualityBadge(context), _buildQualityBadge(context),
], ],
), ),
const Divider(), const Divider(),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildStat( Expanded(
context, child: _buildStat(
'Diametre', context,
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%', 'Étalement',
icon: Icons.straighten, '${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
), ),
_buildStat( Expanded(
context, child: _buildStat(
'Dispersion', context,
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%', 'Dispersion',
icon: Icons.scatter_plot, '${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
), ),
_buildStat( Expanded(
context, child: _buildStat(
'Decalage', context,
offsetDescription, 'Décalage',
icon: Icons.compare_arrows, offsetDescription,
icon: Icons.compare_arrows,
),
), ),
], ],
), ),
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value, value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Text( Text(
label, label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
String _getOffsetDescription(double offsetX, double offsetY) { String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) { if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre'; return 'Centré';
} }
String vertical = ''; String vertical = '';
String horizontal = ''; String horizontal = '';
if (offsetY < -0.02) { if (offsetY < -0.02) {
vertical = 'H'; vertical = 'Haut';
} else if (offsetY > 0.02) { } else if (offsetY > 0.02) {
vertical = 'B'; vertical = 'Bas';
} }
if (offsetX < -0.02) { if (offsetX < -0.02) {
horizontal = 'G'; horizontal = 'Gauche';
} else if (offsetX > 0.02) { } else if (offsetX > 0.02) {
horizontal = 'D'; horizontal = 'Droite';
} }
if (vertical.isNotEmpty && horizontal.isNotEmpty) { if (vertical.isNotEmpty && horizontal.isNotEmpty) {

View File

@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../data/models/target_type.dart'; import '../../../data/models/target_type.dart';
import '../../../services/score_calculator_service.dart'; import '../../../services/score_calculator_service.dart';
@@ -44,6 +45,31 @@ class ScoreCard extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(),
MetricInfoButton(
title: 'Score',
explanations: [
MetricExplanation(
'Total',
'Somme des points de tous vos impacts, sur le maximum '
'possible (nombre d\'impacts × $maxScore points).',
),
const MetricExplanation(
'Impacts',
'Nombre de tirs détectés sur la cible.',
),
MetricExplanation(
'Moyenne',
'Points marqués en moyenne par impact, sur $maxScore.',
),
const MetricExplanation(
'Réussite',
'Votre score exprimé en pourcentage du score maximum '
'possible. C\'est une mesure du résultat, pas de la '
'régularité des tirs.',
),
],
),
], ],
), ),
const Divider(), const Divider(),
@@ -67,11 +93,12 @@ class ScoreCard extends StatelessWidget {
shotCount > 0 shotCount > 0
? (totalScore / shotCount).toStringAsFixed(1) ? (totalScore / shotCount).toStringAsFixed(1)
: '-', : '-',
subtitle: '/ $maxScore',
), ),
if (scoreResult != null) if (scoreResult != null)
_buildScoreStat( _buildScoreStat(
context, context,
'Pourcentage', 'Réussite',
'${scoreResult!.percentage.toStringAsFixed(0)}%', '${scoreResult!.percentage.toStringAsFixed(0)}%',
), ),
], ],

View File

@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
import '../../core/widgets/metric_info_button.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import '../../services/statistics_service.dart'; import '../../services/statistics_service.dart';
@@ -191,18 +192,46 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
'Score', 'Score',
'${_statistics?.totalScore ?? 0}', '${_statistics?.totalScore ?? 0}',
_getScoreHistory(), _getScoreHistory(),
explanations: const [
MetricExplanation(
'Score',
'Total des points marqués sur la période sélectionnée.',
),
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildChartSection( _buildChartSection(
'Précision', 'Précision',
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%', '${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
_getPrecisionHistory(), _getPrecisionHistory(),
explanations: const [
MetricExplanation(
'Précision',
'Proximité moyenne de vos impacts par rapport au centre '
'de la cible (calculée sur les distances).',
),
MetricExplanation(
'À ne pas confondre',
'C\'est différent de la « Réussite » affichée dans une '
'session, qui se base sur les points marqués. Les deux '
'mesurent des choses distinctes, leurs pourcentages '
'ne sont donc pas identiques.',
),
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildChartSection( _buildChartSection(
'Groupement moyen', 'Étalement moyen',
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%', '${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history _getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
explanations: const [
MetricExplanation(
'Étalement moyen',
'Étalement moyen de vos groupements : distance entre les '
'impacts les plus éloignés, en % de la largeur de '
'l\'image. Plus c\'est bas, plus vos tirs sont serrés.',
),
],
), ),
const SizedBox(height: 25), const SizedBox(height: 25),
@@ -302,8 +331,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildChartSection( Widget _buildChartSection(
String title, String title,
String value, String value,
List<double> dataPoints, List<double> dataPoints, {
) { List<MetricExplanation>? explanations,
}) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -314,9 +344,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
title, children: [
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14), Text(
title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
if (explanations != null) ...[
const Spacer(),
MetricInfoButton(title: title, explanations: explanations),
],
],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(