Feat: Ajout du glisser-déposer par appui long pour repositionner les impacts de tir

This commit is contained in:
qguillaume
2026-05-26 17:03:21 +02:00
parent b721583e98
commit f40bfc0ba7
2 changed files with 111 additions and 33 deletions

View File

@@ -39,11 +39,11 @@ class AnalysisProvider extends ChangeNotifier {
DistortionCorrectionService? distortionService, DistortionCorrectionService? distortionService,
OpenCVTargetService? opencvTargetService, OpenCVTargetService? opencvTargetService,
}) : _detectionService = detectionService, }) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService, _scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService, _groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository, _sessionRepository = sessionRepository,
_distortionService = distortionService ?? DistortionCorrectionService(), _distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService(); _opencvTargetService = opencvTargetService ?? OpenCVTargetService();
AnalysisState _state = AnalysisState.initial; AnalysisState _state = AnalysisState.initial;
String? _errorMessage; String? _errorMessage;
@@ -109,19 +109,19 @@ class AnalysisProvider extends ChangeNotifier {
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon) /// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath => String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null _distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath ? _correctedImagePath
: _imagePath; : _imagePath;
/// Analyze an image /// Analyze an image
/// ///
/// [autoAnalyze] determines if we should run automatic detection immediately. /// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set. /// If false, only the image is loaded and default target parameters are set.
Future<void> analyzeImage( Future<void> analyzeImage(
String imagePath, String imagePath,
TargetType targetType, { TargetType targetType, {
bool autoAnalyze = true, bool autoAnalyze = true,
Offset? manualCenter, Offset? manualCenter,
}) async { }) async {
_state = AnalysisState.loading; _state = AnalysisState.loading;
_imagePath = imagePath; _imagePath = imagePath;
_targetType = targetType; _targetType = targetType;
@@ -375,15 +375,15 @@ class AnalysisProvider extends ChangeNotifier {
final detectedImpacts = _detectionService final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences( .detectImpactsWithOpenCVFromReferences(
_imagePath!, _imagePath!,
_targetType!, _targetType!,
_targetCenterX, _targetCenterX,
_targetCenterY, _targetCenterY,
_targetRadius, _targetRadius,
_ringCount, _ringCount,
references, references,
tolerance: tolerance, tolerance: tolerance,
); );
if (clearExisting) { if (clearExisting) {
_shots.clear(); _shots.clear();
@@ -538,7 +538,7 @@ class AnalysisProvider extends ChangeNotifier {
_correctedImagePath = correctedPath; _correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true; _distortionCorrectionEnabled = true;
_imageAspectRatio = _imageAspectRatio =
1.0; // The corrected image is always square (side x side) 1.0; // The corrected image is always square (side x side)
notifyListeners(); notifyListeners();
} }
@@ -683,7 +683,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Exporte l'image et le json vers le backend IA /// Exporte l'image et le json vers le backend IA
Future<bool> exportToAiBackend() async { Future<bool> exportToAiBackend() async {
if (_imagePath == null || _targetType == null) { 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(); notifyListeners();
return false; return false;
} }
@@ -694,7 +694,7 @@ class AnalysisProvider extends ChangeNotifier {
: 'analysis_${DateTime.now().millisecondsSinceEpoch}'; : 'analysis_${DateTime.now().millisecondsSinceEpoch}';
final service = AiExportService(); // Local instanciation for simplicity final service = AiExportService(); // Local instanciation for simplicity
_state = AnalysisState.loading; _state = AnalysisState.loading;
notifyListeners(); notifyListeners();
@@ -796,4 +796,23 @@ class AnalysisProvider extends ChangeNotifier {
_correctedImagePath = null; _correctedImagePath = null;
notifyListeners(); 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();
}
}
}

View File

@@ -92,6 +92,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
final GlobalKey _imageKey = GlobalKey(); final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0; double _currentZoomScale = 1.0;
// AJOUT : Stockage de l'ID du tir en cours de déplacement par appui long
String? _movingShotId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -183,7 +186,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
body: Stack( body: Stack(
children: [ children: [
Navigator.canPop(context) ? const SizedBox.shrink() : const SizedBox.shrink(), // Garde structurellement invisible Navigator.canPop(context) ? const SizedBox.shrink() : const SizedBox.shrink(),
SingleChildScrollView( SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
child: Column( child: Column(
@@ -436,25 +439,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
minScale: 1.0, minScale: 1.0,
maxScale: 10.0, maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(double.infinity), 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( child: GestureDetector(
// CORRECTION : Utilisation de onDoubleTapDown (compatible toutes versions Flutter) // 1. CAPTURE DU DOUBLE-TAP (Ouverture directe de l'édition)
onDoubleTapDown: (TapDownDetails details) { onDoubleTapDown: (TapDownDetails details) {
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return; if (box == null) return;
final localOffset = box.globalToLocal(details.globalPosition); 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 relX = localOffset.dx / box.size.width;
final relY = localOffset.dy / box.size.height; final relY = localOffset.dy / box.size.height;
if (provider.shots.isEmpty) return; if (provider.shots.isEmpty) return;
// Recherche de l'impact le plus proche du double-clic
Shot? closestShot; Shot? closestShot;
double minDistance = double.infinity; double minDistance = double.infinity;
// Seuil de tolérance de détection (environ 5% de la taille de la cible)
const double clickTolerance = 0.05; const double clickTolerance = 0.05;
for (final shot in provider.shots) { 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) { if (closestShot != null) {
_showShotDetails(context, provider, closestShot); _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( child: Stack(
children: [ children: [
Image.file( Image.file(