/// Écran principal de Plotting et d'analyse - Interface centrale de traitement des cibles. /// /// Affiche d'abord la calibration de la cible, puis l'overlay des anneaux et impacts détectés. /// Permet le calcul des scores et statistiques de groupement (Plotting). library; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; import '../../data/models/target_type.dart'; import '../../data/models/shot.dart'; import '../../data/repositories/session_repository.dart'; import '../../services/target_detection_service.dart'; import '../../services/score_calculator_service.dart'; import '../../services/grouping_analyzer_service.dart'; import '../session/session_provider.dart'; import 'analysis_provider.dart'; import '../crop/crop_screen.dart'; import '../capture/capture_screen.dart'; import 'widgets/target_overlay.dart'; import 'widgets/target_calibration.dart'; import 'widgets/score_card.dart'; import 'widgets/grouping_stats.dart'; class AnalysisScreen extends StatelessWidget { final String imagePath; final TargetType targetType; // CORRECTION : Utilisation de coordonnées directes pour effacer l'erreur rouge final double? initialCenterX; final double? initialCenterY; final double? cropScale; final Offset? cropOffset; const AnalysisScreen({ super.key, required this.imagePath, required this.targetType, this.initialCenterX, this.initialCenterY, this.cropScale, this.cropOffset, }); @override Widget build(BuildContext context) { // Reconstitution de l'Offset si présent pour le provider en arrière-plan final manualCenterOffset = (initialCenterX != null && initialCenterY != null) ? Offset(initialCenterX!, initialCenterY!) : null; return ChangeNotifierProvider( create: (context) => AnalysisProvider( detectionService: context.read(), scoreCalculatorService: context.read(), groupingAnalyzerService: context.read(), sessionRepository: context.read(), )..analyzeImage(imagePath, targetType, autoAnalyze: false, manualCenter: manualCenterOffset), child: _AnalysisScreenContent( cropScale: cropScale, cropOffset: cropOffset, ), ); } } class _AnalysisScreenContent extends StatefulWidget { final double? cropScale; final Offset? cropOffset; const _AnalysisScreenContent({ this.cropScale, this.cropOffset, }); @override State<_AnalysisScreenContent> createState() => _AnalysisScreenContentState(); } class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { final GlobalKey _calibrationKey = GlobalKey(); // CORRECTION : Forcé à TRUE pour arriver directement sur la calibration après le Crop ! bool _isCalibrating = true; bool _isSelectingReferences = false; bool _isAtBottom = false; final ScrollController _scrollController = ScrollController(); final TransformationController _transformationController = TransformationController(); final GlobalKey _imageKey = GlobalKey(); double _currentZoomScale = 1.0; // AJOUT : Stockage de l'ID du tir en cours de déplacement par appui long String? _movingShotId; @override void initState() { super.initState(); _transformationController.addListener(_onTransformChanged); _scrollController.addListener(_onScroll); } void _onScroll() { if (!_scrollController.hasClients) return; final isBottom = _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 20; if (isBottom != _isAtBottom) { setState(() { _isAtBottom = isBottom; }); } } @override void dispose() { _transformationController.removeListener(_onTransformChanged); _transformationController.dispose(); _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } void _onTransformChanged() { final scale = _transformationController.value.getMaxScaleOnAxis(); if (scale != _currentZoomScale) { setState(() { _currentZoomScale = scale; }); } } @override Widget build(BuildContext context) { final provider = context.watch(); final sessionProvider = context.watch(); final targetNumber = sessionProvider.isSessionActive ? sessionProvider.targetCount + 1 : null; // CORRECTION : Remplacement du mot "Analyse" par "Plotting" final titlePrefix = _isCalibrating ? 'Calibration' : 'Plotting'; final title = targetNumber != null ? '$titlePrefix - Cible $targetNumber' : ( _isCalibrating ? 'Calibration' : 'Plotting du Tir'); return Scaffold( appBar: AppBar( title: Text(title), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { if (_isCalibrating) { // Si on fait retour pendant la calibration, on retourne sagement au CropScreen final provider = context.read(); Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => CropScreen( imagePath: provider.imagePath!, targetType: provider.targetType!, initialScale: widget.cropScale, initialOffset: widget.cropOffset, ), ), ); } else { // Si on fait retour depuis le Plotting, on revient en arrière dans la calibration setState(() => _isCalibrating = true); } }, ), actions: [ if (!_isCalibrating && !_isSelectingReferences) IconButton( icon: const Icon(Icons.refresh), onPressed: () => provider.analyzeImage(context.read().imagePath!, context.read().targetType!), ), if (_isCalibrating) TextButton( onPressed: () => setState(() => _isCalibrating = false), // Dévoile le Plotting Screen child: const Text( 'TERMINER', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), ), ], ), body: Stack( children: [ Navigator.canPop(context) ? const SizedBox.shrink() : const SizedBox.shrink(), SingleChildScrollView( controller: _scrollController, child: Column( children: [ // Header de chargement Padding( padding: const EdgeInsets.all(AppConstants.defaultPadding), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (provider.state == AnalysisState.loading) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 8.0), child: CircularProgressIndicator(), ), ), ], ), ), // Zone centrale d'affichage : Calibration OU Plotting interactif AspectRatio( aspectRatio: provider.imageAspectRatio, child: _isCalibrating ? Stack( fit: StackFit.expand, children: [ Image.file( File(provider.imagePath!), fit: BoxFit.fill, ), TargetCalibration( key: _calibrationKey, initialCenterX: provider.targetCenterX, initialCenterY: provider.targetCenterY, initialRadius: provider.targetRadius, initialInnerRadius: provider.targetInnerRadius, initialRingCount: provider.ringCount, initialRingRadii: provider.ringRadii, targetType: provider.targetType!, onCalibrationChanged: ( centerX, centerY, innerRadius, radius, ringCount, { List? ringRadii, }) { provider.adjustTargetPosition( centerX, centerY, innerRadius, radius, ringCount: ringCount, ringRadii: ringRadii, ); }, ), ], ) : _buildZoomableImageWithOverlay(context, provider), ), // Vue inférieure : Cartes de scores (Plotting) OU Flèches de réglages (Calibration) if (!_isCalibrating) Padding( padding: const EdgeInsets.all(AppConstants.defaultPadding), child: Column( children: [ // Petit bouton pour réajuster la calibration si besoin Card( color: AppTheme.primaryColor.withValues(alpha: 0.1), child: ListTile( leading: const Icon( Icons.tune, color: AppTheme.primaryColor, ), title: const Text('Ajuster la calibration'), subtitle: const Text( 'Modifier le centre ou le rayon global', ), trailing: const Icon( Icons.arrow_forward_ios, size: 16, ), onTap: () => setState(() => _isCalibrating = true), ), ), const SizedBox(height: 12), // Score card ScoreCard( totalScore: provider.totalScore, shotCount: provider.shotCount, scoreResult: provider.scoreResult, targetType: provider.targetType!, ), const SizedBox(height: 12), // Grouping stats if (provider.groupingResult != null && provider.shotCount > 1) GroupingStats( groupingResult: provider.groupingResult!, targetCenterX: provider.targetCenterX, targetCenterY: provider.targetCenterY, ), const SizedBox(height: 12), _buildActionButtons(context, provider), const SizedBox(height: 50), ], ), ) else // Mode Calibration actif : On affiche les flèches de micro-ajustement Padding( padding: const EdgeInsets.all(AppConstants.defaultPadding), child: Column( children: [ const Text( 'Ajustement precis (pixel par pixel)', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white70, ), ), const SizedBox(height: 8), Center( child: Builder( builder: (context) { final size = MediaQuery.of(context).size; return _calibrationKey.currentState?.buildDirectionalControls(context, size) ?? const SizedBox.shrink(); } ), ), const SizedBox(height: 16), Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Instructions de calibration', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 12), _buildInstructionItem( Icons.open_with, 'Glissez le centre pour positionner le centre de la cible', ), _buildInstructionItem( Icons.zoom_out_map, 'Pincez l\'ecran a 2 doigts ou utilisez la jauge pour la taille', ), _buildInstructionItem( Icons.visibility, 'Appuyez sur TERMINER en haut a droite pour valider', ), const SizedBox(height: 16), Row( children: [ const Text('Centre: '), Text( '(${(provider.targetCenterX * 100).toStringAsFixed(1)}%, ${(provider.targetCenterY * 100).toStringAsFixed(1)}%)', style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), Row( children: [ const Text('Rayon: '), Text( '${(provider.targetRadius * 100).toStringAsFixed(1)}%', style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), ], ), ), ), ], ), ), ], ), ), Positioned( bottom: 0, left: 0, right: 0, child: Align( alignment: _isAtBottom ? Alignment.bottomCenter : Alignment.bottomRight, child: Padding( padding: _isAtBottom ? EdgeInsets.zero : const EdgeInsets.all(16.0), child: _isCalibrating ? const SizedBox.shrink() : FloatingActionButton.extended( onPressed: () => _showSaveSessionDialog(context, provider), backgroundColor: AppTheme.primaryColor, icon: const Icon(Icons.save), label: const Text('TERMINER LA SESSION'), ), ), ), ), ], ), ); } Widget _buildInstructionItem(IconData icon, String text) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ Icon(icon, size: 16, color: AppTheme.primaryColor), const SizedBox(width: 8), Expanded( child: Text( text, style: const TextStyle(fontSize: 13), ), ), ], ), ); } Widget _buildZoomableImageWithOverlay( BuildContext context, AnalysisProvider provider, ) { return InteractiveViewer( transformationController: _transformationController, minScale: 1.0, maxScale: 10.0, boundaryMargin: const EdgeInsets.all(double.infinity), // Désactive le pan à deux doigts quand on déplace un impact pour éviter les tremblements d'image panEnabled: _movingShotId == null, child: GestureDetector( // 1. CAPTURE DU DOUBLE-TAP (Ouverture directe de l'édition) onDoubleTapDown: (TapDownDetails details) { final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; final localOffset = box.globalToLocal(details.globalPosition); final relX = localOffset.dx / box.size.width; final relY = localOffset.dy / box.size.height; if (provider.shots.isEmpty) return; Shot? closestShot; double minDistance = double.infinity; const double clickTolerance = 0.05; for (final shot in provider.shots) { final dx = shot.x - relX; final dy = shot.y - relY; final distance = math.sqrt(dx * dx + dy * dy); if (distance < minDistance && distance < clickTolerance) { minDistance = distance; closestShot = shot; } } if (closestShot != null) { _showShotDetails(context, provider, closestShot); } }, // 2. APPUI LONG : Début de sélection de l'impact à déplacer onLongPressStart: (LongPressStartDetails details) { final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; final localOffset = box.globalToLocal(details.globalPosition); final relX = localOffset.dx / box.size.width; final relY = localOffset.dy / box.size.height; if (provider.shots.isEmpty) return; Shot? closestShot; double minDistance = double.infinity; const double dragTolerance = 0.06; // Tolérance d'accroche un peu plus large pour le doigt for (final shot in provider.shots) { final dx = shot.x - relX; final dy = shot.y - relY; final distance = math.sqrt(dx * dx + dy * dy); if (distance < minDistance && distance < dragTolerance) { minDistance = distance; closestShot = shot; } } // Si on trouve un impact, on active un petit vibreur (si dispo de base) et on verrouille son ID if (closestShot != null) { setState(() { _movingShotId = closestShot!.id; }); } }, // 3. GLISSER : On déplace l'impact verrouillé en temps réel sous le doigt onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) { if (_movingShotId == null) return; final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; final localOffset = box.globalToLocal(details.globalPosition); // Calcul des coordonnées relatives bridées entre 0.0 et 1.0 (pour pas sortir de la photo) final relX = (localOffset.dx / box.size.width).clamp(0.0, 1.0); final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0); // On met à jour directement la position dans le state global via le provider provider.updateShotPosition(_movingShotId!, relX, relY); }, // 4. RELÂCHER : Fin du déplacement de l'impact onLongPressEnd: (_) { if (_movingShotId != null) { setState(() { _movingShotId = null; // Libère le verrou }); } }, child: Stack( children: [ Image.file( File(provider.imagePath!), key: _imageKey, fit: BoxFit.fill, ), TargetOverlay( targetCenterX: provider.targetCenterX, targetCenterY: provider.targetCenterY, targetRadius: provider.targetRadius, targetType: provider.targetType!, shots: provider.shots, showRings: true, zoomScale: _currentZoomScale, onShotTapped: (shot) => _showShotDetails(context, provider, shot), onAddShot: (relX, relY) => provider.addShot(relX, relY), ), ], ), ), ); } Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) { return const Column( children: [ Row( children: [], ), ], ); } void _showShotDetails( BuildContext context, AnalysisProvider provider, Shot shot, ) { showModalBottomSheet( context: context, builder: (context) => Container( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Impact #${provider.shots.indexOf(shot) + 1}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), Text( 'ID: ${shot.id}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10), ), const SizedBox(height: 16), ListTile( leading: const Icon(Icons.score), title: const Text('Modifier le score'), trailing: DropdownButton( value: shot.score.clamp(0, 10), items: List.generate(11, (index) => index) .map((s) => DropdownMenuItem( value: s, child: Text('$s', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), )) .toList(), onChanged: (newScore) { if (newScore != null) { provider.updateShotScore(shot.id, newScore); Navigator.pop(context); } }, ), ), const SizedBox(height: 24), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: () { provider.removeShot(shot.id); Navigator.pop(context); }, icon: const Icon(Icons.delete, color: Colors.red), label: const Text('SUPPRIMER', style: TextStyle(color: Colors.red)), ), ), ], ), ], ), ), ); } void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Session Terminee'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Nombre de tirs: ${provider.shotCount}'), Text('Score total: ${provider.totalScore}'), const SizedBox(height: 16), const Text('Voulez-vous enregistrer cette session ?'), ], ), actions: [ TextButton( onPressed: () { Navigator.pop(context); final path = provider.imagePath!; final type = provider.targetType!; Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => CropScreen( imagePath: path, targetType: type, initialScale: widget.cropScale, initialOffset: widget.cropOffset, ), ), ); }, child: const Text('ANNULER'), ), ElevatedButton( onPressed: () async { try { final sessionProvider = context.read(); final analysis = await provider.saveSession( sessionId: sessionProvider.activeSessionId, weaponName: sessionProvider.currentWeapon, weaponId: sessionProvider.currentWeaponId, distance: sessionProvider.distance, ); sessionProvider.addAnalysis(analysis); if (context.mounted) { Navigator.pop(context); Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => const CaptureScreen()), (route) => route.isFirst, ); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), ); } } }, child: const Text('AJOUTER UNE CIBLE'), ), ElevatedButton( onPressed: () async { try { final sessionProvider = context.read(); await provider.saveSession( sessionId: sessionProvider.activeSessionId, weaponName: sessionProvider.currentWeapon, weaponId: sessionProvider.currentWeaponId, distance: sessionProvider.distance, ); if (context.mounted) { sessionProvider.endSession(); Navigator.pop(context); Navigator.of(context).popUntil((route) => route.isFirst); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), ); } } }, child: const Text('TERMINER TOUT'), ), ], ), ); } }