From f40bfc0ba754f28086ef1084e0c98b7b397391fb Mon Sep 17 00:00:00 2001 From: qguillaume Date: Tue, 26 May 2026 17:03:21 +0200 Subject: [PATCH] =?UTF-8?q?Feat:=20Ajout=20du=20glisser-d=C3=A9poser=20par?= =?UTF-8?q?=20appui=20long=20pour=20repositionner=20les=20impacts=20de=20t?= =?UTF-8?q?ir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/analysis/analysis_provider.dart | 69 +++++++++++------- lib/features/analysis/analysis_screen.dart | 75 +++++++++++++++++--- 2 files changed, 111 insertions(+), 33 deletions(-) diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart index 494c38ee..b8b882ad 100644 --- a/lib/features/analysis/analysis_provider.dart +++ b/lib/features/analysis/analysis_provider.dart @@ -39,11 +39,11 @@ class AnalysisProvider extends ChangeNotifier { DistortionCorrectionService? distortionService, OpenCVTargetService? opencvTargetService, }) : _detectionService = detectionService, - _scoreCalculatorService = scoreCalculatorService, - _groupingAnalyzerService = groupingAnalyzerService, - _sessionRepository = sessionRepository, - _distortionService = distortionService ?? DistortionCorrectionService(), - _opencvTargetService = opencvTargetService ?? OpenCVTargetService(); + _scoreCalculatorService = scoreCalculatorService, + _groupingAnalyzerService = groupingAnalyzerService, + _sessionRepository = sessionRepository, + _distortionService = distortionService ?? DistortionCorrectionService(), + _opencvTargetService = opencvTargetService ?? OpenCVTargetService(); AnalysisState _state = AnalysisState.initial; String? _errorMessage; @@ -109,19 +109,19 @@ class AnalysisProvider extends ChangeNotifier { /// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon) String? get displayImagePath => _distortionCorrectionEnabled && _correctedImagePath != null - ? _correctedImagePath - : _imagePath; + ? _correctedImagePath + : _imagePath; /// Analyze an image /// /// [autoAnalyze] determines if we should run automatic detection immediately. /// If false, only the image is loaded and default target parameters are set. Future analyzeImage( - String imagePath, - TargetType targetType, { - bool autoAnalyze = true, - Offset? manualCenter, - }) async { + String imagePath, + TargetType targetType, { + bool autoAnalyze = true, + Offset? manualCenter, + }) async { _state = AnalysisState.loading; _imagePath = imagePath; _targetType = targetType; @@ -375,15 +375,15 @@ class AnalysisProvider extends ChangeNotifier { final detectedImpacts = _detectionService .detectImpactsWithOpenCVFromReferences( - _imagePath!, - _targetType!, - _targetCenterX, - _targetCenterY, - _targetRadius, - _ringCount, - references, - tolerance: tolerance, - ); + _imagePath!, + _targetType!, + _targetCenterX, + _targetCenterY, + _targetRadius, + _ringCount, + references, + tolerance: tolerance, + ); if (clearExisting) { _shots.clear(); @@ -538,7 +538,7 @@ class AnalysisProvider extends ChangeNotifier { _correctedImagePath = correctedPath; _distortionCorrectionEnabled = true; _imageAspectRatio = - 1.0; // The corrected image is always square (side x side) + 1.0; // The corrected image is always square (side x side) notifyListeners(); } @@ -683,7 +683,7 @@ class AnalysisProvider extends ChangeNotifier { /// Exporte l'image et le json vers le backend IA Future exportToAiBackend() async { if (_imagePath == null || _targetType == null) { - _errorMessage = "Impossible d'exporter : image ou type de cible manquant."; + _errorMessage = "Impossible d'export : image ou type de cible manquant."; notifyListeners(); return false; } @@ -694,7 +694,7 @@ class AnalysisProvider extends ChangeNotifier { : 'analysis_${DateTime.now().millisecondsSinceEpoch}'; final service = AiExportService(); // Local instanciation for simplicity - + _state = AnalysisState.loading; notifyListeners(); @@ -796,4 +796,23 @@ class AnalysisProvider extends ChangeNotifier { _correctedImagePath = null; notifyListeners(); } -} + + /// Met à jour la position d'un impact de tir en direct (Glisser-Déposer). + void updateShotPosition(String shotId, double newX, double newY) { + final index = _shots.indexWhere((s) => s.id == shotId); + if (index != -1) { + // 1. On calcule dynamiquement le nouveau score de la zone survolée + final newScore = _calculateShotScore(newX, newY); + + // 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés + _shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore); + + // 3. On applique les deux méthodes natives de recalcul de ton application + _recalculateScores(); + _recalculateGrouping(); + + // 4. On demande à l'écran de se redessiner en direct + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 6c43d991..99aeb056 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -92,6 +92,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { 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(); @@ -183,7 +186,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ), body: Stack( children: [ - Navigator.canPop(context) ? const SizedBox.shrink() : const SizedBox.shrink(), // Garde structurellement invisible + Navigator.canPop(context) ? const SizedBox.shrink() : const SizedBox.shrink(), SingleChildScrollView( controller: _scrollController, child: Column( @@ -436,25 +439,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { 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( - // CORRECTION : Utilisation de onDoubleTapDown (compatible toutes versions Flutter) + // 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); - - // Coordonnées relatives (entre 0.0 et 1.0) sous le doigt final relX = localOffset.dx / box.size.width; final relY = localOffset.dy / box.size.height; if (provider.shots.isEmpty) return; - // Recherche de l'impact le plus proche du double-clic Shot? closestShot; double minDistance = double.infinity; - - // Seuil de tolérance de détection (environ 5% de la taille de la cible) const double clickTolerance = 0.05; for (final shot in provider.shots) { @@ -468,11 +468,70 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { } } - // Si un impact est détecté sous le double-clic, on ouvre ses options ! 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(