Files
impact/lib/features/analysis/widgets/grouping_stats.dart
qguillaume 1d8124c8d8 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>
2026-06-16 10:27:10 +02:00

316 lines
9.2 KiB
Dart

/// Statistiques de groupement des impacts.
///
/// Affiche le diamètre de groupement, l'écart-type et le décalage par rapport
/// au centre de la cible. Inclut un indicateur de qualité (Excellent, Bon, Moyen, Faible).
library;
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../services/grouping_analyzer_service.dart';
class GroupingStats extends StatelessWidget {
final GroupingResult groupingResult;
final double targetCenterX;
final double targetCenterY;
const GroupingStats({
super.key,
required this.groupingResult,
required this.targetCenterX,
required this.targetCenterY,
});
@override
Widget build(BuildContext context) {
final offsetX = groupingResult.centerX - targetCenterX;
final offsetY = groupingResult.centerY - targetCenterY;
final offsetDescription = _getOffsetDescription(offsetX, offsetY);
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.center_focus_strong, color: AppTheme.groupingCenterColor),
const SizedBox(width: 8),
Text(
'Groupement',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
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(),
_buildQualityBadge(context),
],
),
const Divider(),
Row(
children: [
Expanded(
child: _buildStat(
context,
'Étalement',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
),
Expanded(
child: _buildStat(
context,
'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
),
Expanded(
child: _buildStat(
context,
'Décalage',
offsetDescription,
icon: Icons.compare_arrows,
),
),
],
),
const SizedBox(height: 12),
_buildOffsetIndicator(context, offsetX, offsetY),
],
),
),
);
}
Widget _buildQualityBadge(BuildContext context) {
final rating = groupingResult.qualityRating;
final color = _getQualityColor(rating);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
...List.generate(5, (index) {
return Icon(
index < rating ? Icons.star : Icons.star_border,
size: 16,
color: color,
);
}),
const SizedBox(width: 4),
Text(
groupingResult.qualityDescription,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
);
}
Widget _buildStat(
BuildContext context,
String label,
String value, {
required IconData icon,
}) {
return Column(
children: [
Icon(icon, size: 20, color: Colors.grey),
const SizedBox(height: 4),
Text(
value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildOffsetIndicator(BuildContext context, double offsetX, double offsetY) {
return Container(
height: 80,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
// Grid lines
Center(
child: Container(
width: 1,
color: Colors.grey[300],
),
),
Center(
child: Container(
height: 1,
color: Colors.grey[300],
),
),
// Center point (target)
const Center(
child: Icon(Icons.add, size: 16, color: Colors.grey),
),
// Grouping center
LayoutBuilder(
builder: (context, constraints) {
// Scale offset for visualization (max 40 pixels from center)
final maxOffset = 40.0;
final scaledX = (offsetX * 200).clamp(-maxOffset, maxOffset);
final scaledY = (offsetY * 200).clamp(-maxOffset, maxOffset);
return Center(
child: Transform.translate(
offset: Offset(scaledX, scaledY),
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: AppTheme.groupingCenterColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
);
},
),
// Labels
Positioned(
top: 4,
left: 0,
right: 0,
child: Text(
'Haut',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
Positioned(
bottom: 4,
left: 0,
right: 0,
child: Text(
'Bas',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
Positioned(
left: 4,
top: 0,
bottom: 0,
child: Center(
child: Text(
'G',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
),
Positioned(
right: 4,
top: 0,
bottom: 0,
child: Center(
child: Text(
'D',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
),
],
),
);
}
Color _getQualityColor(int rating) {
switch (rating) {
case 5:
return AppTheme.successColor;
case 4:
return Colors.lightGreen;
case 3:
return AppTheme.warningColor;
case 2:
return Colors.orange;
default:
return AppTheme.errorColor;
}
}
String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centré';
}
String vertical = '';
String horizontal = '';
if (offsetY < -0.02) {
vertical = 'Haut';
} else if (offsetY > 0.02) {
vertical = 'Bas';
}
if (offsetX < -0.02) {
horizontal = 'Gauche';
} else if (offsetX > 0.02) {
horizontal = 'Droite';
}
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
return '$vertical-$horizontal';
}
return vertical.isNotEmpty ? vertical : horizontal;
}
}