/// É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 '../../data/models/shot.dart'; import 'analysis_provider.dart'; import 'widgets/target_overlay.dart'; class ImpactEditorScreen extends StatefulWidget { const ImpactEditorScreen({super.key}); @override State createState() => _ImpactEditorScreenState(); } class _ImpactEditorScreenState extends State { 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(); 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( 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), ), ), ), ], ), ], ), ), ); } }