fix: trying to avoid conflict first with tap and pin on plotting screen

This commit is contained in:
qguillaume
2026-06-08 23:27:44 +02:00
parent 625810234e
commit 71ad670ad8
2 changed files with 105 additions and 70 deletions

View File

@@ -530,6 +530,46 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
..rotateZ((provider.cropRotation) * (math.pi / 180)), ..rotateZ((provider.cropRotation) * (math.pi / 180)),
alignment: Alignment.center, alignment: Alignment.center,
child: GestureDetector( 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) { onDoubleTapDown: (TapDownDetails details) {
final RenderBox? box = final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?; _imageKey.currentContext?.findRenderObject() as RenderBox?;
@@ -632,7 +672,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
zoomScale: _currentZoomScale, zoomScale: _currentZoomScale,
onShotTapped: (shot) => onShotTapped: (shot) =>
_showShotDetails(context, provider, 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.
), ),
], ],
), ),

View File

@@ -1,8 +1,9 @@
/// Overlay visuel de la cible. /// Overlay visuel de la cible.
/// ///
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement /// 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 /// et les impacts de référence. Gère uniquement la SÉLECTION d'impacts existants
/// d'impacts et 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; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -48,18 +49,17 @@ class TargetOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( // IMPORTANT : plus de GestureDetector global ici.
builder: (context, constraints) { // L'ancien GestureDetector (onTapUp couvrant toute la surface, en
return GestureDetector( // HitTestBehavior.translucent) volait les pointeurs au pinch de
behavior: HitTestBehavior.translucent, // l'InteractiveViewer parent et rendait le zoom capricieux.
onTapUp: (details) { //
if (onAddShot != null) { // Désormais :
// Utiliser les constraints pour un calcul précis // - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
final relX = details.localPosition.dx / constraints.maxWidth; // - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
final relY = details.localPosition.dy / constraints.maxHeight; // zones de tap localisées (deferToChild) placées sur chaque impact.
onAddShot!(relX, relY); return IgnorePointer(
} ignoring: false,
},
child: CustomPaint( child: CustomPaint(
painter: _TargetOverlayPainter( painter: _TargetOverlayPainter(
shots: shots, shots: shots,
@@ -76,48 +76,42 @@ class TargetOverlay extends StatelessWidget {
zoomScale: zoomScale, zoomScale: zoomScale,
showRings: showRings, showRings: showRings,
), ),
child: Stack(
children: shots.map((shot) {
return Positioned(
left: 0,
top: 0,
right: 0,
bottom: 0,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, innerConstraints) { builder: (context, constraints) {
final x = shot.x * innerConstraints.maxWidth; return Stack(
final y = shot.y * innerConstraints.maxHeight; children: shots.map((shot) {
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran) 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 tapSize = 30 / zoomScale;
final halfTapSize = tapSize / 2; final halfTapSize = tapSize / 2;
return Stack( return Positioned(
children: [
Positioned(
left: x - halfTapSize, left: x - halfTapSize,
top: y - halfTapSize, top: y - halfTapSize,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, // 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), onTap: () => onShotTapped?.call(shot),
child: Container( child: Container(
width: tapSize, width: tapSize,
height: tapSize, height: tapSize,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.transparent, // 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, shape: BoxShape.circle,
), ),
), ),
), ),
),
],
);
},
),
); );
}).toList(), }).toList(),
),
),
); );
}, },
),
),
); );
} }
} }
@@ -300,9 +294,9 @@ class _TargetOverlayPainter extends CustomPainter {
..strokeWidth = strokeWidth; ..strokeWidth = strokeWidth;
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint); 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() final impactPaint = Paint()
..color = AppTheme.impactColor.withValues(alpha: 0.2) ..color = AppTheme.impactColor.withValues(alpha: 0.3)
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint); canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);