feat: implement base architecture and core repositories for weapon tracking and target analysis functionality

This commit is contained in:
streaper2
2026-05-08 20:29:18 +02:00
parent 5dd58da51c
commit 774dbfcf40
37 changed files with 3687 additions and 2713 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
@@ -24,6 +26,8 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Valeurs pour les Dropdowns (Filtres)
String _selectedWeapon = 'Toutes';
String _selectedDistance = 'Toutes';
List<String> _availableWeapons = ['Toutes'];
List<String> _availableDistances = ['Toutes'];
@override
void initState() {
@@ -37,6 +41,35 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
try {
final repository = context.read<SessionRepository>();
_allSessions = await repository.getAllSessions();
// Extraire les armes et distances uniques pour les filtres
final weaponsFromSessions = _allSessions.map((s) => s.weapon).toSet();
// Récupérer aussi les armes de l'armurerie pour s'assurer qu'elles sont présentes
final armoryWeapons = await repository.getWeapons();
final weaponsFromArmory = armoryWeapons.map((w) => w.displayName).toSet();
// Combiner et trier
final allWeaponsList = {...weaponsFromSessions, ...weaponsFromArmory}.toList()..sort();
final distances = _allSessions.map((s) => '${s.distance}m').toSet().toList()
..sort((a, b) {
final distA = int.tryParse(a.replaceAll('m', '')) ?? 0;
final distB = int.tryParse(b.replaceAll('m', '')) ?? 0;
return distA.compareTo(distB);
});
_availableWeapons = ['Toutes', ...allWeaponsList];
_availableDistances = ['Toutes', ...distances];
// Sécurité : si l'arme sélectionnée n'est plus disponible, on reset
if (!_availableWeapons.contains(_selectedWeapon)) {
_selectedWeapon = 'Toutes';
}
if (!_availableDistances.contains(_selectedDistance)) {
_selectedDistance = 'Toutes';
}
_calculateStats();
} catch (e) {
debugPrint('Error: $e');
@@ -46,8 +79,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
}
void _calculateStats() {
// Filtrer les sessions avant calcul
final filteredSessions = _allSessions.where((s) {
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
final distanceMatch = _selectedDistance == 'Toutes' || '${s.distance}m' == _selectedDistance;
return weaponMatch && distanceMatch;
}).toList();
_statistics = _statisticsService.calculateStatistics(
_allSessions,
filteredSessions,
period: _selectedPeriod,
);
}
@@ -68,46 +108,57 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
final sortedSessions = List<Session>.from(_statistics!.sessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return sortedSessions.map((s) {
if (s.shots.isEmpty) return 0.0;
// Simple precision proxy: score / max possible (assuming 10 is max)
return (s.totalScore / (s.shots.length * 10)) * 100;
if (s.totalShots == 0) return 0.0;
// Simple precision proxy: total score / max possible (assuming 10 is max per shot)
return (s.totalScore / (s.totalShots * 10)) * 100;
}).toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(
0xFF121212,
), // Fond sombre comme sur le design
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'Statistiques',
style: TextStyle(color: Colors.white),
),
title: const Text('Statistiques'),
centerTitle: true,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
: RefreshIndicator(
onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 1. FILTRES (Arme et Distance)
Row(
children: [
Expanded(
child: _buildDropdown('Arme utilisée', _selectedWeapon),
child: _buildDropdown(
'Arme utilisée',
_selectedWeapon,
_availableWeapons,
(val) {
setState(() {
_selectedWeapon = val!;
_calculateStats();
});
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdown('Distance', _selectedDistance),
child: _buildDropdown(
'Distance',
_selectedDistance,
_availableDistances,
(val) {
setState(() {
_selectedDistance = val!;
_calculateStats();
});
},
),
),
],
),
@@ -162,71 +213,55 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(height: 20),
_buildQuadrantGrid(),
const SizedBox(height: 20),
_buildHeatMapSection(),
const SizedBox(height: 20),
if (_statistics!.regional.biasX.abs() > 0.05 ||
_statistics!.regional.biasY.abs() > 0.05)
_buildBiasWarning(),
],
const SizedBox(height: 30),
// 4. BOUTON ACCUEIL
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () => Navigator.of(
context,
).popUntil((route) => route.isFirst),
child: const Text(
'ACCUEIL',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
);
}
// Widget pour les Dropdowns de filtres
Widget _buildDropdown(String label, String value) {
Widget _buildDropdown(
String label,
String value,
List<String> items,
void Function(String?) onChanged,
) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white12),
border: Border.all(color: theme.dividerColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(color: Colors.white54, fontSize: 10),
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10),
),
DropdownButton<String>(
value: value,
isExpanded: true,
underline: Container(),
dropdownColor: const Color(0xFF1E1E1E),
style: const TextStyle(color: Colors.white, fontSize: 14),
items: [value]
dropdownColor: theme.colorScheme.surfaceContainerHighest,
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14),
items: items
.map(
(String val) =>
DropdownMenuItem(value: val, child: Text(val)),
)
.toList(),
onChanged: (newValue) {},
onChanged: onChanged,
),
],
),
@@ -235,10 +270,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Widget pour les petites cartes de stats
Widget _buildQuickStat(String label, String value, IconData icon) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -247,15 +283,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: theme.textTheme.titleLarge?.color,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12),
),
],
),
@@ -268,10 +304,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
String value,
List<double> dataPoints,
) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -279,15 +316,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [
Text(
title,
style: const TextStyle(color: Colors.white70, fontSize: 14),
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
const SizedBox(height: 10),
Row(
children: [
Text(
value,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: theme.textTheme.headlineMedium?.color,
fontSize: 24,
fontWeight: FontWeight.bold,
),
@@ -328,7 +365,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: const Color(0xFF4CAF50).withOpacity(0.2),
color: const Color(0xFF4CAF50).withValues(alpha: 0.2),
),
),
],
@@ -339,19 +376,20 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildRegionalDistribution() {
final regional = _statistics!.regional;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Distribution Régionale',
style: TextStyle(
color: Colors.white,
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -363,7 +401,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(width: 8),
Text(
'Direction dominante : ${regional.dominantDirection}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
],
),
@@ -378,13 +416,13 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF1A73E8).withOpacity(0.1),
color: const Color(0xFF1A73E8).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF1A73E8).withOpacity(0.3)),
border: Border.all(color: const Color(0xFF1A73E8).withValues(alpha: 0.3)),
),
child: Text(
'${e.key}: ${e.value} (${percentage.toStringAsFixed(0)}%)',
style: const TextStyle(color: Colors.white, fontSize: 12),
style: TextStyle(color: theme.textTheme.bodySmall?.color, fontSize: 12),
),
);
}).toList(),
@@ -396,19 +434,20 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildQuadrantGrid() {
final quadrants = _statistics!.regional.quadrantDistribution;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Répartition par Quadrant',
style: TextStyle(
color: Colors.white,
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -435,6 +474,79 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
);
}
Widget _buildHeatMapSection() {
final heatMap = _statistics!.heatMap;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Zones de Chaleur (Heatmap)',
style: TextStyle(
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Analyse de densité des impacts',
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12),
),
const SizedBox(height: 20),
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CustomPaint(
painter: _HeatMapPainter(heatMap: heatMap),
),
),
),
),
const SizedBox(height: 16),
// Légende dégradée
Container(
height: 12,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
gradient: const LinearGradient(
colors: [
Colors.blue,
Colors.yellow,
Colors.orange,
Colors.red,
],
),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Faible', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.5))),
Text('Élevée', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.5))),
],
),
],
),
);
}
Widget _buildQuadrantCell(String label, int count) {
final percentage = (count / _statistics!.totalShots * 100);
final intensity = count / _statistics!.totalShots;
@@ -442,10 +554,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
return Container(
decoration: BoxDecoration(
color: Color.lerp(
const Color(0xFF2A2A2A),
Theme.of(context).colorScheme.surfaceContainerHighest,
const Color(0xFF1A73E8),
intensity,
)!.withOpacity(0.8),
)!.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(8),
),
child: Column(
@@ -453,19 +565,19 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [
Text(
'$count',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
'${percentage.toStringAsFixed(0)}%',
style: const TextStyle(color: Colors.white70, fontSize: 12),
style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7), fontSize: 12),
),
Text(
label,
style: const TextStyle(color: Colors.white54, fontSize: 10),
style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.5), fontSize: 10),
),
],
),
@@ -486,9 +598,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
children: [
@@ -507,7 +619,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
),
Text(
'Tendance $description',
style: const TextStyle(color: Colors.white70, fontSize: 13),
style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 13),
),
],
),
@@ -517,3 +629,109 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
);
}
}
class _HeatMapPainter extends CustomPainter {
final HeatMap heatMap;
_HeatMapPainter({required this.heatMap});
@override
void paint(Canvas canvas, Size size) {
_drawTargetBackground(canvas, size);
_drawHeatGradient(canvas, size);
}
void _drawTargetBackground(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = math.min(size.width, size.height) * 0.45;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.grey.withValues(alpha: 0.2); // Neutre
// Draw rings
for (int i = 1; i <= 10; i++) {
canvas.drawCircle(center, maxRadius * (i / 10), paint);
}
// Draw crosshair
canvas.drawLine(Offset(0, center.dy), Offset(size.width, center.dy), paint);
canvas.drawLine(Offset(center.dx, 0), Offset(center.dx, size.height), paint);
}
void _drawHeatGradient(Canvas canvas, Size size) {
final cellSizeW = size.width / heatMap.gridSize;
final cellSizeH = size.height / heatMap.gridSize;
for (var row in heatMap.zones) {
for (var zone in row) {
if (zone.intensity <= 0) continue;
final center = Offset(
(zone.col + 0.5) * cellSizeW,
(zone.row + 0.5) * cellSizeH,
);
final radius = cellSizeW * 1.2; // Légèrement réduit pour plus de netteté
// Normalisation de l'intensité : on rend le max toujours très visible (rouge)
final normalizedIntensity = zone.intensity;
final color = _getIntensityColor(normalizedIntensity);
final paint = Paint()
..shader = ui.Gradient.radial(
center,
radius,
[
color.withValues(alpha: 0.7 * normalizedIntensity.clamp(0.4, 1.0)),
color.withValues(alpha: 0.0),
],
[0.2, 1.0],
)
..style = PaintingStyle.fill; // srcOver par défaut, plus lisible
canvas.drawCircle(center, radius, paint);
// --- AFFICHAGE DU NOMBRE D'IMPACTS ---
if (zone.shotCount > 0) {
final textPainter = TextPainter(
text: TextSpan(
text: '${zone.shotCount}',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
offset: const Offset(1, 1),
),
],
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(center.dx - textPainter.width / 2, center.dy - textPainter.height / 2),
);
}
}
}
}
Color _getIntensityColor(double intensity) {
if (intensity < 0.2) return Colors.blue;
if (intensity < 0.5) return Colors.yellow;
if (intensity < 0.8) return Colors.orange;
return Colors.red;
}
@override
bool shouldRepaint(covariant _HeatMapPainter oldDelegate) =>
heatMap != oldDelegate.heatMap;
}