feat: refonte des statistiques (00C) et correction ergonomique de la capture (10C)
This commit is contained in:
@@ -20,11 +20,11 @@ class CaptureScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _CaptureScreenState extends State<CaptureScreen> {
|
class _CaptureScreenState extends State<CaptureScreen> {
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
TargetType _selectedType = TargetType.concentric;
|
final TargetType _selectedType = TargetType.concentric;
|
||||||
String? _selectedImagePath;
|
String? _selectedImagePath;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
/// Gère la demande de permission et la sélection d'image
|
/// Gère la demande de permission et la sélection d'image depuis la galerie
|
||||||
Future<void> _handleGallerySelection() async {
|
Future<void> _handleGallerySelection() async {
|
||||||
PermissionStatus status;
|
PermissionStatus status;
|
||||||
|
|
||||||
@@ -32,7 +32,6 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
final deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
final androidInfo = await deviceInfo.androidInfo;
|
final androidInfo = await deviceInfo.androidInfo;
|
||||||
|
|
||||||
// Android 13+ (SDK 33) utilise .photos, les versions précédentes utilisent .storage
|
|
||||||
if (androidInfo.version.sdkInt >= 33) {
|
if (androidInfo.version.sdkInt >= 33) {
|
||||||
status = await Permission.photos.request();
|
status = await Permission.photos.request();
|
||||||
} else {
|
} else {
|
||||||
@@ -106,9 +105,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
child: ImageSourceButton(
|
child: ImageSourceButton(
|
||||||
icon: Icons.photo_library,
|
icon: Icons.photo_library,
|
||||||
label: 'Galerie',
|
label: 'Galerie',
|
||||||
onPressed: _isLoading
|
onPressed: _isLoading ? null : _handleGallerySelection,
|
||||||
? null
|
|
||||||
: _handleGallerySelection, // Appelle la nouvelle fonction
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -126,21 +123,44 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
_buildImagePreview()
|
_buildImagePreview()
|
||||||
else
|
else
|
||||||
_buildGuide(),
|
_buildGuide(),
|
||||||
|
|
||||||
|
// --- NOUVEAU BOUTON VALIDER ---
|
||||||
|
if (_selectedImagePath != null && !_isLoading) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF112233), // Ton bleu foncé
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 54),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
onPressed: _analyzeImage,
|
||||||
|
child: const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Valider',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Icon(Icons.arrow_forward, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: _selectedImagePath != null
|
|
||||||
? FloatingActionButton.extended(
|
|
||||||
onPressed: _analyzeImage,
|
|
||||||
icon: const Icon(Icons.arrow_forward),
|
|
||||||
label: const Text('Suivant'),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tes widgets _buildSectionTitle, _buildImagePreview, _buildFramingHints restent identiques
|
|
||||||
|
|
||||||
Widget _buildSectionTitle(String title) {
|
Widget _buildSectionTitle(String title) {
|
||||||
return Text(
|
return Text(
|
||||||
title,
|
title,
|
||||||
@@ -230,7 +250,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
_buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
|
_buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
|
||||||
_buildGuideItem(
|
_buildGuideItem(
|
||||||
Icons.cleaning_services,
|
Icons.cleaning_services,
|
||||||
'Nettoyer votre objectif avec une chiffonnette pour lunette si nécessaire',
|
'Nettoyer votre objectif avec une chiffonnette',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -251,8 +271,6 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LOGIQUE DE CAPTURE ---
|
|
||||||
|
|
||||||
Future<void> _scanDocument() async {
|
Future<void> _scanDocument() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
/// Écran des statistiques détaillées.
|
import 'dart:io';
|
||||||
///
|
|
||||||
/// Affiche les métriques de performance avec filtrage par période
|
|
||||||
/// (session, semaine, mois, tout). Inclut heat map, précision,
|
|
||||||
/// écart-type et distribution régionale des tirs.
|
|
||||||
library;
|
|
||||||
|
|
||||||
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 '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.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';
|
||||||
import 'widgets/heat_map_widget.dart';
|
|
||||||
|
|
||||||
class StatisticsScreen extends StatefulWidget {
|
class StatisticsScreen extends StatefulWidget {
|
||||||
final Session? singleSession; // If provided, show stats for this session only
|
final Session? singleSession;
|
||||||
|
|
||||||
const StatisticsScreen({super.key, this.singleSession});
|
const StatisticsScreen({super.key, this.singleSession});
|
||||||
|
|
||||||
@@ -30,720 +24,282 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
List<Session> _allSessions = [];
|
List<Session> _allSessions = [];
|
||||||
|
|
||||||
|
// Valeurs pour les Dropdowns (Filtres)
|
||||||
|
String _selectedWeapon = 'Toutes';
|
||||||
|
String _selectedDistance = 'Toutes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Use addPostFrameCallback to ensure context is available
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_loadStatistics();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadStatistics() async {
|
Future<void> _loadStatistics() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (widget.singleSession != null) {
|
|
||||||
// Single session mode
|
|
||||||
_statistics = _statisticsService.calculateStatistics(
|
|
||||||
[widget.singleSession!],
|
|
||||||
period: StatsPeriod.session,
|
|
||||||
targetCenterX: widget.singleSession!.targetCenterX ?? 0.5,
|
|
||||||
targetCenterY: widget.singleSession!.targetCenterY ?? 0.5,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Load all sessions
|
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
_allSessions = await repository.getAllSessions();
|
_allSessions = await repository.getAllSessions();
|
||||||
_calculateStats();
|
_calculateStats();
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading statistics: $e');
|
debugPrint('Error: $e');
|
||||||
}
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _calculateStats() {
|
void _calculateStats() {
|
||||||
debugPrint(
|
|
||||||
'Calculating stats for ${_allSessions.length} sessions, period: $_selectedPeriod',
|
|
||||||
);
|
|
||||||
for (final session in _allSessions) {
|
|
||||||
debugPrint(
|
|
||||||
' Session: ${session.id}, shots: ${session.shots.length}, date: ${session.createdAt}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_statistics = _statisticsService.calculateStatistics(
|
_statistics = _statisticsService.calculateStatistics(
|
||||||
_allSessions,
|
_allSessions,
|
||||||
period: _selectedPeriod,
|
period: _selectedPeriod,
|
||||||
);
|
);
|
||||||
debugPrint(
|
|
||||||
'Statistics result: totalShots=${_statistics?.totalShots}, totalScore=${_statistics?.totalScore}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(
|
||||||
|
0xFF121212,
|
||||||
|
), // Fond sombre comme sur le design
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
backgroundColor: Colors.transparent,
|
||||||
widget.singleSession != null
|
elevation: 0,
|
||||||
? 'Statistiques Session'
|
leading: IconButton(
|
||||||
: 'Statistiques',
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Statistiques',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _statistics == null || _statistics!.totalShots == 0
|
: SingleChildScrollView(
|
||||||
? _buildEmptyState()
|
padding: const EdgeInsets.all(16),
|
||||||
: _buildStatistics(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// 1. FILTRES (Arme et Distance)
|
||||||
Icons.analytics_outlined,
|
Row(
|
||||||
size: 64,
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Aucune donnee disponible',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Effectuez des sessions de tir pour voir vos statistiques',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Sessions trouvees: ${_allSessions.length}',
|
|
||||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
|
||||||
),
|
|
||||||
if (_allSessions.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
'Tirs totaux: ${_allSessions.fold<int>(0, (sum, s) => sum + s.shots.length)}',
|
|
||||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStatistics() {
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: _loadStatistics,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Period filter (only for multi-session view)
|
|
||||||
if (widget.singleSession == null) _buildPeriodFilter(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Summary cards
|
|
||||||
_buildSummaryCards(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Heat Map
|
|
||||||
_buildHeatMapSection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Precision stats
|
|
||||||
_buildPrecisionSection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Standard deviation
|
|
||||||
_buildStdDevSection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Regional distribution
|
|
||||||
_buildRegionalSection(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPeriodFilter() {
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Periode',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SegmentedButton<StatsPeriod>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: StatsPeriod.week,
|
|
||||||
label: Text('7 jours'),
|
|
||||||
icon: Icon(Icons.date_range),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: StatsPeriod.month,
|
|
||||||
label: Text('30 jours'),
|
|
||||||
icon: Icon(Icons.calendar_month),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: StatsPeriod.all,
|
|
||||||
label: Text('Tout'),
|
|
||||||
icon: Icon(Icons.all_inclusive),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {_selectedPeriod},
|
|
||||||
onSelectionChanged: (selection) {
|
|
||||||
setState(() {
|
|
||||||
_selectedPeriod = selection.first;
|
|
||||||
_calculateStats();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'${_statistics!.sessions.length} session(s) - ${_statistics!.totalShots} tir(s)',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSummaryCards() {
|
|
||||||
return Row(
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _StatCard(
|
child: _buildDropdown('Arme utilisée', _selectedWeapon),
|
||||||
icon: Icons.gps_fixed,
|
),
|
||||||
title: 'Tirs',
|
const SizedBox(width: 12),
|
||||||
value: '${_statistics!.totalShots}',
|
Expanded(
|
||||||
color: AppTheme.primaryColor,
|
child: _buildDropdown('Distance', _selectedDistance),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildQuickStat(
|
||||||
|
'Nbre de tirs',
|
||||||
|
'${_statistics?.totalShots ?? 0}',
|
||||||
|
Icons.gps_fixed,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _StatCard(
|
child: _buildQuickStat(
|
||||||
icon: Icons.score,
|
'Nbre de sessions',
|
||||||
title: 'Score Total',
|
'${_statistics?.sessions.length ?? 0}',
|
||||||
value: '${_statistics!.totalScore}',
|
Icons.analytics,
|
||||||
color: AppTheme.secondaryColor,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
}
|
const SizedBox(height: 25),
|
||||||
|
|
||||||
Widget _buildHeatMapSection() {
|
// 3. LES GRAPHIQUES
|
||||||
return Card(
|
_buildChartSection(
|
||||||
child: Padding(
|
'Score',
|
||||||
padding: const EdgeInsets.all(16),
|
'${_statistics?.totalScore ?? 0}',
|
||||||
child: Column(
|
[8, 12, 10, 15, 14, 18],
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.grid_on, color: AppTheme.primaryColor),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Zones Chaudes',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 20),
|
||||||
|
_buildChartSection(
|
||||||
|
'Précision',
|
||||||
|
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
||||||
|
[60, 75, 70, 85, 80, 95],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 20),
|
||||||
Text(
|
_buildChartSection('Groupement moyen', '14.2 mm', [
|
||||||
'Repartition de vos tirs sur la cible',
|
20,
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
18,
|
||||||
),
|
22,
|
||||||
const SizedBox(height: 16),
|
15,
|
||||||
Center(
|
14,
|
||||||
child: HeatMapWidget(
|
12,
|
||||||
heatMap: _statistics!.heatMap,
|
]), // mm fictif pour l'exemple
|
||||||
size: MediaQuery.of(context).size.width - 80,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// Legend - gradient bar
|
|
||||||
Container(
|
|
||||||
height: 24,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color(0xFF2196F3), // Blue (cold)
|
|
||||||
Color(0xFF00BCD4), // Cyan
|
|
||||||
Color(0xFFFFEB3B), // Yellow
|
|
||||||
Color(0xFFFF9800), // Orange
|
|
||||||
Color(0xFFFF1744), // Red (hot)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 16),
|
|
||||||
child: Text(
|
|
||||||
'Peu',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
child: Text(
|
|
||||||
'Beaucoup',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPrecisionSection() {
|
const SizedBox(height: 30),
|
||||||
final precision = _statistics!.precision;
|
|
||||||
|
|
||||||
return Card(
|
// 4. BOUTON ACCUEIL
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.center_focus_strong,
|
|
||||||
color: AppTheme.successColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Precision',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildPrecisionGauge(
|
|
||||||
'Precision',
|
|
||||||
precision.precisionScore,
|
|
||||||
'Distance moyenne du centre',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: _buildPrecisionGauge(
|
|
||||||
'Regularite',
|
|
||||||
precision.consistencyScore,
|
|
||||||
'Groupement des tirs',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
|
||||||
_buildStatRow(
|
|
||||||
'Distance moyenne du centre',
|
|
||||||
'${(precision.avgDistanceFromCenter * 100).toStringAsFixed(1)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Diametre de groupement',
|
|
||||||
'${(precision.groupingDiameter * 100).toStringAsFixed(1)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Score moyen',
|
|
||||||
_statistics!.avgScore.toStringAsFixed(2),
|
|
||||||
),
|
|
||||||
_buildStatRow('Meilleur score', '${_statistics!.maxScore}'),
|
|
||||||
_buildStatRow('Plus bas score', '${_statistics!.minScore}'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPrecisionGauge(String title, double value, String subtitle) {
|
|
||||||
final color = value > 70
|
|
||||||
? AppTheme.successColor
|
|
||||||
: value > 40
|
|
||||||
? AppTheme.warningColor
|
|
||||||
: AppTheme.errorColor;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 80,
|
width: double.infinity,
|
||||||
height: 80,
|
height: 50,
|
||||||
child: CircularProgressIndicator(
|
child: ElevatedButton(
|
||||||
value: value / 100,
|
style: ElevatedButton.styleFrom(
|
||||||
strokeWidth: 8,
|
backgroundColor: const Color(0xFF1A73E8),
|
||||||
backgroundColor: Colors.grey.shade200,
|
shape: RoundedRectangleBorder(
|
||||||
valueColor: AlwaysStoppedAnimation(color),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
value.toStringAsFixed(0),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStdDevSection() {
|
|
||||||
final stdDev = _statistics!.stdDev;
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.stacked_line_chart,
|
|
||||||
color: AppTheme.warningColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Ecart Type',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Mesure de la dispersion de vos tirs',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildStatRow(
|
|
||||||
'Ecart type X (horizontal)',
|
|
||||||
'${(stdDev.stdDevX * 100).toStringAsFixed(2)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Ecart type Y (vertical)',
|
|
||||||
'${(stdDev.stdDevY * 100).toStringAsFixed(2)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Ecart type radial',
|
|
||||||
'${(stdDev.stdDevRadial * 100).toStringAsFixed(2)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Ecart type score',
|
|
||||||
stdDev.stdDevScore.toStringAsFixed(2),
|
|
||||||
),
|
|
||||||
const Divider(height: 24),
|
|
||||||
_buildStatRow(
|
|
||||||
'Position moyenne X',
|
|
||||||
'${(stdDev.meanX * 100).toStringAsFixed(1)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Position moyenne Y',
|
|
||||||
'${(stdDev.meanY * 100).toStringAsFixed(1)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow('Score moyen', stdDev.meanScore.toStringAsFixed(2)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRegionalSection() {
|
|
||||||
final regional = _statistics!.regional;
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.explore, color: AppTheme.secondaryColor),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Distribution Regionale',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Dominant direction
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.compass_calibration,
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
onPressed: () => Navigator.of(
|
||||||
Expanded(
|
context,
|
||||||
child: Column(
|
).popUntil((route) => route.isFirst),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: const Text(
|
||||||
children: [
|
'ACCUEIL',
|
||||||
const Text('Direction dominante'),
|
|
||||||
Text(
|
|
||||||
regional.dominantDirection,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Bias
|
|
||||||
if (regional.biasX.abs() > 0.02 || regional.biasY.abs() > 0.02)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.warningColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.warning_amber,
|
|
||||||
color: AppTheme.warningColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('Biais detecte'),
|
|
||||||
Text(
|
|
||||||
_getBiasDescription(regional.biasX, regional.biasY),
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Sector distribution
|
|
||||||
const Text(
|
|
||||||
'Repartition par secteur:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: regional.sectorDistribution.entries.map((entry) {
|
|
||||||
final percentage = _statistics!.totalShots > 0
|
|
||||||
? (entry.value / _statistics!.totalShots * 100)
|
|
||||||
: 0.0;
|
|
||||||
return _buildSectorChip(entry.key, entry.value, percentage);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Quadrant distribution
|
|
||||||
const Text(
|
|
||||||
'Repartition par quadrant:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildQuadrantGrid(regional.quadrantDistribution),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStatRow(String label, String value) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(label),
|
|
||||||
Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSectorChip(String sector, int count, double percentage) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: count > 0
|
|
||||||
? AppTheme.primaryColor.withValues(alpha: 0.1)
|
|
||||||
: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'$sector: $count (${percentage.toStringAsFixed(0)}%)',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
color: Colors.white,
|
||||||
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade600,
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuadrantGrid(Map<String, int> quadrants) {
|
// Widget pour les Dropdowns de filtres
|
||||||
return Table(
|
Widget _buildDropdown(String label, String value) {
|
||||||
border: TableBorder.all(color: Colors.grey.shade300),
|
|
||||||
children: [
|
|
||||||
TableRow(
|
|
||||||
children: [
|
|
||||||
_buildQuadrantCell('Haut-Gauche', quadrants['Haut-Gauche'] ?? 0),
|
|
||||||
_buildQuadrantCell('Haut-Droite', quadrants['Haut-Droite'] ?? 0),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TableRow(
|
|
||||||
children: [
|
|
||||||
_buildQuadrantCell('Bas-Gauche', quadrants['Bas-Gauche'] ?? 0),
|
|
||||||
_buildQuadrantCell('Bas-Droite', quadrants['Bas-Droite'] ?? 0),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildQuadrantCell(String label, int count) {
|
|
||||||
final percentage = _statistics!.totalShots > 0
|
|
||||||
? (count / _statistics!.totalShots * 100)
|
|
||||||
: 0.0;
|
|
||||||
final intensity = _statistics!.totalShots > 0
|
|
||||||
? count / _statistics!.totalShots
|
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
color: Color.lerp(Colors.white, AppTheme.primaryColor, intensity * 0.5),
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E1E1E),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
|
||||||
'$count',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${percentage.toStringAsFixed(0)}%',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(fontSize: 10),
|
style: const TextStyle(color: Colors.white54, fontSize: 10),
|
||||||
textAlign: TextAlign.center,
|
),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: value,
|
||||||
|
isExpanded: true,
|
||||||
|
underline: Container(),
|
||||||
|
dropdownColor: const Color(0xFF1E1E1E),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
items: [value]
|
||||||
|
.map(
|
||||||
|
(String val) =>
|
||||||
|
DropdownMenuItem(value: val, child: Text(val)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (newValue) {},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getBiasDescription(double biasX, double biasY) {
|
// Widget pour les petites cartes de stats
|
||||||
final descriptions = <String>[];
|
Widget _buildQuickStat(String label, String value, IconData icon) {
|
||||||
|
return Container(
|
||||||
if (biasX.abs() > 0.02) {
|
|
||||||
descriptions.add(biasX > 0 ? 'vers la droite' : 'vers la gauche');
|
|
||||||
}
|
|
||||||
if (biasY.abs() > 0.02) {
|
|
||||||
descriptions.add(biasY > 0 ? 'vers le bas' : 'vers le haut');
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Tendance ${descriptions.join(' et ')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatCard extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String value;
|
|
||||||
final Color color;
|
|
||||||
|
|
||||||
const _StatCard({
|
|
||||||
required this.icon,
|
|
||||||
required this.title,
|
|
||||||
required this.value,
|
|
||||||
required this.color,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E1E1E),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: color, size: 32),
|
Icon(icon, color: const Color(0xFF1A73E8), size: 20),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 24,
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: color,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(title, style: TextStyle(color: Colors.grey.shade600)),
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget pour une section de graphique
|
||||||
|
Widget _buildChartSection(
|
||||||
|
String title,
|
||||||
|
String value,
|
||||||
|
List<double> dataPoints,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E1E1E),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 60,
|
||||||
|
child: LineChart(_mainChartData(dataPoints)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LineChartData _mainChartData(List<double> points) {
|
||||||
|
return LineChartData(
|
||||||
|
gridData: const FlGridData(show: false),
|
||||||
|
titlesData: const FlTitlesData(show: false),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: points
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) => FlSpot(e.key.toDouble(), e.value))
|
||||||
|
.toList(),
|
||||||
|
isCurved: true,
|
||||||
|
color: const Color(0xFF4CAF50), // Vert comme sur ton design
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: const FlDotData(show: false),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
color: const Color(0xFF4CAF50).withOpacity(0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,10 +197,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: fl_chart
|
name: fl_chart
|
||||||
sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d"
|
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.66.2"
|
version: "1.2.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ dependencies:
|
|||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
|
||||||
# Charts for history visualization
|
# Charts for history visualization
|
||||||
fl_chart: ^0.66.2
|
fl_chart: ^1.2.0
|
||||||
|
|
||||||
# Image processing for impact detection
|
# Image processing for impact detection
|
||||||
image: ^4.1.7
|
image: ^4.1.7
|
||||||
|
|||||||
Reference in New Issue
Block a user