diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index e2dae3f3..ec95cfa3 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -530,6 +530,46 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ..rotateZ((provider.cropRotation) * (math.pi / 180)), alignment: Alignment.center, child: GestureDetector( + // AJOUT D'IMPACT : géré ici, sur le GestureDetector parent, par un + // simple tap. Un seul détecteur de tap -> plus de couche concurrente + // avec l'InteractiveViewer, donc le pinch/zoom redevient fiable. + // + // Flutter ne déclenche onTapUp que si le doigt n'a pas bougé au-delà + // du seuil (touch slop) : un déplacement part en pan via + // l'InteractiveViewer, un toucher bref pose un impact. + onTapUp: (TapUpDetails details) { + // Pendant le déplacement d'un impact, on n'ajoute rien. + if (_movingShotId != null) return; + + 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).clamp(0.0, 1.0); + final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0); + + // Si le tap tombe sur un impact existant, on l'ouvre plutôt que + // d'en empiler un nouveau par-dessus. + Shot? hitShot; + double minDistance = double.infinity; + const double hitTolerance = 0.04; + 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 < hitTolerance) { + minDistance = distance; + hitShot = shot; + } + } + + if (hitShot != null) { + _showShotDetails(context, provider, hitShot); + } else { + provider.addShot(relX, relY); + } + }, onDoubleTapDown: (TapDownDetails details) { final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; @@ -632,7 +672,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { zoomScale: _currentZoomScale, onShotTapped: (shot) => _showShotDetails(context, provider, shot), - onAddShot: (relX, relY) => provider.addShot(relX, relY), + // onAddShot retiré : l'ajout est géré par le GestureDetector + // parent (onTapUp) pour ne pas concurrencer le pinch/zoom. ), ], ), diff --git a/lib/features/analysis/widgets/target_overlay.dart b/lib/features/analysis/widgets/target_overlay.dart index 3258e3d0..bcd760cf 100644 --- a/lib/features/analysis/widgets/target_overlay.dart +++ b/lib/features/analysis/widgets/target_overlay.dart @@ -1,8 +1,9 @@ /// Overlay visuel de la cible. /// /// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement -/// et les impacts de référence. Gère les interactions tactiles pour l'ajout -/// d'impacts et la sélection d'impacts existants. +/// et les impacts de référence. Gère uniquement la SÉLECTION d'impacts existants +/// (tap sur un impact). L'AJOUT d'un impact est délégué à l'écran parent pour +/// éviter tout conflit de gestes avec le zoom/pan de l'InteractiveViewer. library; import 'package:flutter/material.dart'; @@ -48,76 +49,69 @@ class TargetOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTapUp: (details) { - if (onAddShot != null) { - // Utiliser les constraints pour un calcul précis - final relX = details.localPosition.dx / constraints.maxWidth; - final relY = details.localPosition.dy / constraints.maxHeight; - onAddShot!(relX, relY); - } - }, - child: CustomPaint( - painter: _TargetOverlayPainter( - shots: shots, - targetCenterX: targetCenterX, - targetCenterY: targetCenterY, - targetRadius: targetRadius, - targetType: targetType, - ringCount: ringCount, - ringRadii: ringRadii, - groupingCenterX: groupingCenterX, - groupingCenterY: groupingCenterY, - groupingDiameter: groupingDiameter, - referenceImpacts: referenceImpacts, - zoomScale: zoomScale, - showRings: showRings, - ), - child: Stack( + // IMPORTANT : plus de GestureDetector global ici. + // L'ancien GestureDetector (onTapUp couvrant toute la surface, en + // HitTestBehavior.translucent) volait les pointeurs au pinch de + // l'InteractiveViewer parent et rendait le zoom capricieux. + // + // Désormais : + // - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen). + // - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites + // zones de tap localisées (deferToChild) placées sur chaque impact. + return IgnorePointer( + ignoring: false, + child: CustomPaint( + painter: _TargetOverlayPainter( + shots: shots, + targetCenterX: targetCenterX, + targetCenterY: targetCenterY, + targetRadius: targetRadius, + targetType: targetType, + ringCount: ringCount, + ringRadii: ringRadii, + groupingCenterX: groupingCenterX, + groupingCenterY: groupingCenterY, + groupingDiameter: groupingDiameter, + referenceImpacts: referenceImpacts, + zoomScale: zoomScale, + showRings: showRings, + ), + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( children: shots.map((shot) { + final x = shot.x * constraints.maxWidth; + final y = shot.y * constraints.maxHeight; + // Zone de tap qui reste constante à l'écran malgré le zoom. + final tapSize = 30 / zoomScale; + final halfTapSize = tapSize / 2; return Positioned( - left: 0, - top: 0, - right: 0, - bottom: 0, - child: LayoutBuilder( - builder: (context, innerConstraints) { - final x = shot.x * innerConstraints.maxWidth; - final y = shot.y * innerConstraints.maxHeight; - // Zone de tap qui s'adapte au zoom (taille fixe à l'écran) - final tapSize = 30 / zoomScale; - final halfTapSize = tapSize / 2; - return Stack( - children: [ - Positioned( - left: x - halfTapSize, - top: y - halfTapSize, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => onShotTapped?.call(shot), - child: Container( - width: tapSize, - height: tapSize, - decoration: const BoxDecoration( - color: Colors.transparent, - shape: BoxShape.circle, - ), - ), - ), - ), - ], - ); - }, + left: x - halfTapSize, + top: y - halfTapSize, + child: GestureDetector( + // deferToChild : ne capte le toucher QUE sur la zone du + // Container (un cercle opaque au hit-test), pas ailleurs. + // Le reste de la surface reste donc disponible pour le + // pinch/pan de l'InteractiveViewer. + behavior: HitTestBehavior.deferToChild, + onTap: () => onShotTapped?.call(shot), + child: Container( + width: tapSize, + height: tapSize, + decoration: const BoxDecoration( + // Opaque pour le hit-test (couleur transparente visuellement + // mais non nulle), pour que le tap soit bien capté ici. + color: Color(0x01000000), + shape: BoxShape.circle, + ), + ), ), ); }).toList(), - ), - ), - ); - }, + ); + }, + ), + ), ); } } @@ -300,9 +294,9 @@ class _TargetOverlayPainter extends CustomPainter { ..strokeWidth = strokeWidth; canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint); - // Draw impact marker — TRANSPARENCE 50% pour voir l'impact réel derrière + // Draw impact marker — TRANSPARENCE 30% pour voir l'impact réel derrière final impactPaint = Paint() - ..color = AppTheme.impactColor.withValues(alpha: 0.2) + ..color = AppTheme.impactColor.withValues(alpha: 0.3) ..style = PaintingStyle.fill; canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);