feat: adding new plotting screen fixing pan and tap
This commit is contained in:
@@ -19,6 +19,7 @@ import '../../services/score_calculator_service.dart';
|
|||||||
import '../../services/grouping_analyzer_service.dart';
|
import '../../services/grouping_analyzer_service.dart';
|
||||||
import '../session/session_provider.dart';
|
import '../session/session_provider.dart';
|
||||||
import 'analysis_provider.dart';
|
import 'analysis_provider.dart';
|
||||||
|
import 'impact_editor_screen.dart';
|
||||||
import '../crop/crop_screen.dart';
|
import '../crop/crop_screen.dart';
|
||||||
import '../capture/capture_screen.dart';
|
import '../capture/capture_screen.dart';
|
||||||
import 'widgets/target_overlay.dart';
|
import 'widgets/target_overlay.dart';
|
||||||
@@ -168,6 +169,36 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
setState(() => _isCalibrating = true);
|
setState(() => _isCalibrating = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant.
|
||||||
|
///
|
||||||
|
/// On utilise ChangeNotifierProvider.value pour que l'éditeur lise et modifie
|
||||||
|
/// exactement le même AnalysisProvider que cet écran : les impacts ajoutés,
|
||||||
|
/// déplacés ou supprimés sont donc immédiatement répercutés ici.
|
||||||
|
///
|
||||||
|
/// Au retour : si l'utilisateur a validé (résultat true) on bascule en mode
|
||||||
|
/// Plotting (lecture seule) ; sinon on repasse en calibration.
|
||||||
|
Future<void> _openImpactEditor(AnalysisProvider provider) async {
|
||||||
|
final validated = await Navigator.of(context).push<bool>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
|
||||||
|
value: provider,
|
||||||
|
child: const ImpactEditorScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (validated == true) {
|
||||||
|
setState(() {
|
||||||
|
_isCalibrating = false;
|
||||||
|
_isSelectingReferences = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_enterCalibration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
|
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
|
||||||
///
|
///
|
||||||
/// On privilégie TOUJOURS l'image source non rognée (originalImagePath).
|
/// On privilégie TOUJOURS l'image source non rognée (originalImagePath).
|
||||||
@@ -230,14 +261,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
if (_isCalibrating)
|
if (_isCalibrating)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// On fige la calibration courante dans le provider AVANT de
|
// On fige la calibration courante AVANT d'ouvrir l'éditeur,
|
||||||
// basculer en Plotting, pour que l'instance Plotting parte
|
// puis on passe sur l'écran d'édition d'impacts plein écran
|
||||||
// d'un état figé et indépendant des rebuilds de calibration.
|
// (zoom fiable + placement). Le mode Plotting (lecture seule)
|
||||||
|
// s'affichera au retour si l'utilisateur valide.
|
||||||
_calibrationKey.currentState?.commitCalibration();
|
_calibrationKey.currentState?.commitCalibration();
|
||||||
setState(() {
|
_openImpactEditor(context.read<AnalysisProvider>());
|
||||||
_isCalibrating = false;
|
|
||||||
_isSelectingReferences = false;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'TERMINER',
|
'TERMINER',
|
||||||
@@ -332,7 +361,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: _buildZoomableImageWithOverlay(context, provider),
|
: _buildReadOnlyPlotImage(context, provider),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (!_isCalibrating)
|
if (!_isCalibrating)
|
||||||
@@ -340,6 +369,27 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
Card(
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.18),
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.edit_location_alt,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
title: const Text('Modifier les impacts'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Ajouter, déplacer ou supprimer des impacts (plein écran)',
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.open_in_full,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
// Rouvre l'éditeur plein écran en partageant le provider.
|
||||||
|
onTap: () =>
|
||||||
|
_openImpactEditor(context.read<AnalysisProvider>()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Card(
|
Card(
|
||||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@@ -509,154 +559,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildZoomableImageWithOverlay(
|
/// Affichage du plotting en LECTURE SEULE.
|
||||||
|
///
|
||||||
|
/// L'édition (ajout / déplacement / suppression) se fait désormais
|
||||||
|
/// exclusivement dans l'éditeur plein écran (ImpactEditorScreen). Ici on se
|
||||||
|
/// contente d'afficher l'image + l'overlay, avec un zoom de consultation.
|
||||||
|
/// Le tap sur un impact ouvre simplement ses détails.
|
||||||
|
Widget _buildReadOnlyPlotImage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
AnalysisProvider provider,
|
AnalysisProvider provider,
|
||||||
) {
|
) {
|
||||||
// IMPORTANT : on ne met PLUS de Transform (rotation/offset) entre
|
|
||||||
// l'InteractiveViewer et son contenu.
|
|
||||||
//
|
|
||||||
// 1) La rotation est déjà appliquée physiquement dans le fichier par
|
|
||||||
// cropToSquare (rotationDegrees), donc la ré-appliquer ici est redondant.
|
|
||||||
// 2) Surtout, un Transform inséré dans le child d'un InteractiveViewer
|
|
||||||
// fausse le calcul du point focal du pinch : c'était la vraie cause du
|
|
||||||
// zoom "impossible". On le retire donc complètement.
|
|
||||||
//
|
|
||||||
// boundaryMargin est aussi ramené à une valeur finie : une marge infinie
|
|
||||||
// pousse l'InteractiveViewer à interpréter certains gestes deux-doigts
|
|
||||||
// comme du déplacement libre au lieu d'un scale.
|
|
||||||
return InteractiveViewer(
|
return InteractiveViewer(
|
||||||
transformationController: _transformationController,
|
transformationController: _transformationController,
|
||||||
minScale: 1.0,
|
minScale: 1.0,
|
||||||
maxScale: 10.0,
|
maxScale: 10.0,
|
||||||
boundaryMargin: const EdgeInsets.all(80),
|
boundaryMargin: const EdgeInsets.all(80),
|
||||||
panEnabled: _movingShotId == null,
|
panEnabled: true,
|
||||||
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?;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (closestShot != null) {
|
|
||||||
setState(() {
|
|
||||||
_movingShotId = closestShot!.id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) {
|
|
||||||
if (_movingShotId == null) return;
|
|
||||||
|
|
||||||
final RenderBox? box =
|
|
||||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
|
||||||
if (box == null) return;
|
|
||||||
|
|
||||||
final adjustedGlobalPosition =
|
|
||||||
details.globalPosition + const Offset(-25, -35);
|
|
||||||
final localOffset = box.globalToLocal(adjustedGlobalPosition);
|
|
||||||
|
|
||||||
final relX = (localOffset.dx / box.size.width).clamp(0.0, 1.0);
|
|
||||||
final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
provider.updateShotPosition(_movingShotId!, relX, relY);
|
|
||||||
},
|
|
||||||
onLongPressEnd: (_) {
|
|
||||||
if (_movingShotId != null) {
|
|
||||||
setState(() {
|
|
||||||
_movingShotId = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Image.file(
|
Image.file(
|
||||||
@@ -664,7 +582,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
key: _imageKey,
|
key: _imageKey,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
TargetOverlay(
|
Positioned.fill(
|
||||||
|
child: TargetOverlay(
|
||||||
targetCenterX: provider.targetCenterX,
|
targetCenterX: provider.targetCenterX,
|
||||||
targetCenterY: provider.targetCenterY,
|
targetCenterY: provider.targetCenterY,
|
||||||
targetRadius: provider.targetRadius,
|
targetRadius: provider.targetRadius,
|
||||||
@@ -672,14 +591,13 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
shots: provider.shots,
|
shots: provider.shots,
|
||||||
showRings: true,
|
showRings: true,
|
||||||
zoomScale: _currentZoomScale,
|
zoomScale: _currentZoomScale,
|
||||||
|
// Lecture seule : tap sur impact -> détails (consultation).
|
||||||
onShotTapped: (shot) =>
|
onShotTapped: (shot) =>
|
||||||
_showShotDetails(context, provider, shot),
|
_showShotDetails(context, provider, shot),
|
||||||
// onAddShot retiré : l'ajout est géré par le GestureDetector
|
),
|
||||||
// parent (onTapUp) pour ne pas concurrencer le pinch/zoom.
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
284
lib/features/analysis/impact_editor_screen.dart
Normal file
284
lib/features/analysis/impact_editor_screen.dart
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/// Écran d'édition des impacts — PLEIN ÉCRAN dédié au zoom et au placement.
|
||||||
|
///
|
||||||
|
/// Cet écran est volontairement minimal : un Scaffold dont le body est
|
||||||
|
/// directement un InteractiveViewer (sans SingleScrollView ni AspectRatio
|
||||||
|
/// contraint autour). C'est la configuration la plus fiable pour le pinch :
|
||||||
|
/// l'InteractiveViewer reçoit les deux doigts sans concurrence avec un
|
||||||
|
/// scroll vertical ou une transformation parente.
|
||||||
|
///
|
||||||
|
/// Interactions :
|
||||||
|
/// - Tap sur zone vide -> ajoute un impact
|
||||||
|
/// - Tap sur un impact -> ouvre l'édition (score / suppression)
|
||||||
|
/// - Appui long + glisser -> déplace l'impact
|
||||||
|
///
|
||||||
|
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
|
||||||
|
/// AnalysisProvider (passé en ChangeNotifierProvider.value côté appelant).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../data/models/shot.dart';
|
||||||
|
import 'analysis_provider.dart';
|
||||||
|
import 'widgets/target_overlay.dart';
|
||||||
|
|
||||||
|
class ImpactEditorScreen extends StatefulWidget {
|
||||||
|
const ImpactEditorScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImpactEditorScreen> createState() => _ImpactEditorScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||||
|
final TransformationController _transformationController =
|
||||||
|
TransformationController();
|
||||||
|
final GlobalKey _imageKey = GlobalKey();
|
||||||
|
|
||||||
|
double _currentZoomScale = 1.0;
|
||||||
|
String? _movingShotId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_transformationController.addListener(_onTransformChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_transformationController.removeListener(_onTransformChanged);
|
||||||
|
_transformationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTransformChanged() {
|
||||||
|
final scale = _transformationController.value.getMaxScaleOnAxis();
|
||||||
|
if (scale != _currentZoomScale) {
|
||||||
|
setState(() => _currentZoomScale = scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit une position globale en coordonnées relatives (0..1) sur l'image.
|
||||||
|
Offset? _toImageRelative(Offset globalPosition) {
|
||||||
|
final RenderBox? box =
|
||||||
|
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null) return null;
|
||||||
|
final local = box.globalToLocal(globalPosition);
|
||||||
|
final relX = (local.dx / box.size.width).clamp(0.0, 1.0);
|
||||||
|
final relY = (local.dy / box.size.height).clamp(0.0, 1.0);
|
||||||
|
return Offset(relX, relY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
|
||||||
|
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
|
||||||
|
{double tolerance = 0.04}) {
|
||||||
|
Shot? closest;
|
||||||
|
double minDistance = double.infinity;
|
||||||
|
for (final shot in provider.shots) {
|
||||||
|
final dx = shot.x - rel.dx;
|
||||||
|
final dy = shot.y - rel.dy;
|
||||||
|
final distance = math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance < minDistance && distance < tolerance) {
|
||||||
|
minDistance = distance;
|
||||||
|
closest = shot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<AnalysisProvider>();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
title: Text('Placement des impacts (${provider.shotCount})'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
tooltip: 'Retour à la calibration',
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text(
|
||||||
|
'VALIDER',
|
||||||
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Bandeau d'aide compact
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.white10,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: const Text(
|
||||||
|
'Tap : ajouter • Tap sur impact : éditer • Appui long : déplacer • Pincer : zoomer',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Zone image plein écran : InteractiveViewer dans un body nu.
|
||||||
|
Expanded(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
transformationController: _transformationController,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 12.0,
|
||||||
|
boundaryMargin: const EdgeInsets.all(80),
|
||||||
|
panEnabled: _movingShotId == null,
|
||||||
|
child: Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
// TAP : éditer si on touche un impact, sinon ajouter.
|
||||||
|
onTapUp: (details) {
|
||||||
|
if (_movingShotId != null) return;
|
||||||
|
final rel = _toImageRelative(details.globalPosition);
|
||||||
|
if (rel == null) return;
|
||||||
|
|
||||||
|
final hit = _hitTestShot(provider, rel);
|
||||||
|
if (hit != null) {
|
||||||
|
_showShotDetails(context, provider, hit);
|
||||||
|
} else {
|
||||||
|
provider.addShot(rel.dx, rel.dy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
|
||||||
|
onLongPressStart: (details) {
|
||||||
|
final rel = _toImageRelative(details.globalPosition);
|
||||||
|
if (rel == null) return;
|
||||||
|
final hit = _hitTestShot(provider, rel, tolerance: 0.06);
|
||||||
|
if (hit != null) {
|
||||||
|
setState(() => _movingShotId = hit.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPressMoveUpdate: (details) {
|
||||||
|
if (_movingShotId == null) return;
|
||||||
|
// Décalage pour que l'impact reste visible au-dessus du doigt.
|
||||||
|
final adjusted =
|
||||||
|
details.globalPosition + const Offset(-25, -35);
|
||||||
|
final rel = _toImageRelative(adjusted);
|
||||||
|
if (rel == null) return;
|
||||||
|
provider.updateShotPosition(
|
||||||
|
_movingShotId!, rel.dx, rel.dy);
|
||||||
|
},
|
||||||
|
onLongPressEnd: (_) {
|
||||||
|
if (_movingShotId != null) {
|
||||||
|
setState(() => _movingShotId = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Image.file(
|
||||||
|
File(provider.imagePath!),
|
||||||
|
key: _imageKey,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: TargetOverlay(
|
||||||
|
targetCenterX: provider.targetCenterX,
|
||||||
|
targetCenterY: provider.targetCenterY,
|
||||||
|
targetRadius: provider.targetRadius,
|
||||||
|
targetType: provider.targetType!,
|
||||||
|
shots: provider.shots,
|
||||||
|
showRings: true,
|
||||||
|
zoomScale: _currentZoomScale,
|
||||||
|
// L'ajout et la sélection sont gérés par le
|
||||||
|
// GestureDetector parent ci-dessus.
|
||||||
|
onShotTapped: (shot) =>
|
||||||
|
_showShotDetails(context, provider, shot),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<int>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user