diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index b367d700..30cc8fd6 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -19,6 +19,7 @@ import '../../services/score_calculator_service.dart'; import '../../services/grouping_analyzer_service.dart'; import '../session/session_provider.dart'; import 'analysis_provider.dart'; +import 'impact_editor_screen.dart'; import '../crop/crop_screen.dart'; import '../capture/capture_screen.dart'; import 'widgets/target_overlay.dart'; @@ -168,6 +169,36 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { 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 _openImpactEditor(AnalysisProvider provider) async { + final validated = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChangeNotifierProvider.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. /// /// On privilégie TOUJOURS l'image source non rognée (originalImagePath). @@ -230,14 +261,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { if (_isCalibrating) TextButton( onPressed: () { - // On fige la calibration courante dans le provider AVANT de - // basculer en Plotting, pour que l'instance Plotting parte - // d'un état figé et indépendant des rebuilds de calibration. + // On fige la calibration courante AVANT d'ouvrir l'éditeur, + // puis on passe sur l'écran d'édition d'impacts plein écran + // (zoom fiable + placement). Le mode Plotting (lecture seule) + // s'affichera au retour si l'utilisateur valide. _calibrationKey.currentState?.commitCalibration(); - setState(() { - _isCalibrating = false; - _isSelectingReferences = false; - }); + _openImpactEditor(context.read()); }, child: const Text( 'TERMINER', @@ -332,7 +361,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ), ], ) - : _buildZoomableImageWithOverlay(context, provider), + : _buildReadOnlyPlotImage(context, provider), ), if (!_isCalibrating) @@ -340,6 +369,27 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { padding: const EdgeInsets.all(AppConstants.defaultPadding), child: Column( 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()), + ), + ), + const SizedBox(height: 12), Card( color: AppTheme.primaryColor.withValues(alpha: 0.1), child: ListTile( @@ -509,162 +559,31 @@ 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, 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( transformationController: _transformationController, minScale: 1.0, maxScale: 10.0, boundaryMargin: const EdgeInsets.all(80), - panEnabled: _movingShotId == null, - 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( - children: [ - Image.file( - File(provider.imagePath!), - key: _imageKey, - fit: BoxFit.contain, - ), - TargetOverlay( + panEnabled: true, + 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, @@ -672,13 +591,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { shots: provider.shots, showRings: true, zoomScale: _currentZoomScale, + // Lecture seule : tap sur impact -> détails (consultation). onShotTapped: (shot) => _showShotDetails(context, provider, shot), - // onAddShot retiré : l'ajout est géré par le GestureDetector - // parent (onTapUp) pour ne pas concurrencer le pinch/zoom. ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/analysis/impact_editor_screen.dart b/lib/features/analysis/impact_editor_screen.dart new file mode 100644 index 00000000..8bb54cc9 --- /dev/null +++ b/lib/features/analysis/impact_editor_screen.dart @@ -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 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), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file