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,76 +49,69 @@ 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, targetCenterX: targetCenterX,
targetCenterX: targetCenterX, targetCenterY: targetCenterY,
targetCenterY: targetCenterY, targetRadius: targetRadius,
targetRadius: targetRadius, targetType: targetType,
targetType: targetType, ringCount: ringCount,
ringCount: ringCount, ringRadii: ringRadii,
ringRadii: ringRadii, groupingCenterX: groupingCenterX,
groupingCenterX: groupingCenterX, groupingCenterY: groupingCenterY,
groupingCenterY: groupingCenterY, groupingDiameter: groupingDiameter,
groupingDiameter: groupingDiameter, referenceImpacts: referenceImpacts,
referenceImpacts: referenceImpacts, zoomScale: zoomScale,
zoomScale: zoomScale, showRings: showRings,
showRings: showRings, ),
), child: LayoutBuilder(
child: Stack( builder: (context, constraints) {
return Stack(
children: shots.map((shot) { 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( return Positioned(
left: 0, left: x - halfTapSize,
top: 0, top: y - halfTapSize,
right: 0, child: GestureDetector(
bottom: 0, // deferToChild : ne capte le toucher QUE sur la zone du
child: LayoutBuilder( // Container (un cercle opaque au hit-test), pas ailleurs.
builder: (context, innerConstraints) { // Le reste de la surface reste donc disponible pour le
final x = shot.x * innerConstraints.maxWidth; // pinch/pan de l'InteractiveViewer.
final y = shot.y * innerConstraints.maxHeight; behavior: HitTestBehavior.deferToChild,
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran) onTap: () => onShotTapped?.call(shot),
final tapSize = 30 / zoomScale; child: Container(
final halfTapSize = tapSize / 2; width: tapSize,
return Stack( height: tapSize,
children: [ decoration: const BoxDecoration(
Positioned( // Opaque pour le hit-test (couleur transparente visuellement
left: x - halfTapSize, // mais non nulle), pour que le tap soit bien capté ici.
top: y - halfTapSize, color: Color(0x01000000),
child: GestureDetector( shape: BoxShape.circle,
behavior: HitTestBehavior.translucent, ),
onTap: () => onShotTapped?.call(shot), ),
child: Container(
width: tapSize,
height: tapSize,
decoration: const BoxDecoration(
color: Colors.transparent,
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);