From 3812cd740bce07f25323f77e1d10d1aa429a04de Mon Sep 17 00:00:00 2001 From: qlionbleusam Date: Fri, 7 Aug 2026 12:04:49 +0200 Subject: [PATCH] feat: tap simple toujours createur d'impact + ecran de plotting renomme Synthese --- CLAUDE.md | 8 +- lib/features/analysis/analysis_provider.dart | 2 +- lib/features/analysis/analysis_screen.dart | 30 ++++--- .../analysis/impact_editor_screen.dart | 39 ++++----- .../analysis/widgets/shot_details_sheet.dart | 85 ------------------- .../analysis/widgets/target_overlay.dart | 9 +- 6 files changed, 49 insertions(+), 124 deletions(-) delete mode 100644 lib/features/analysis/widgets/shot_details_sheet.dart diff --git a/CLAUDE.md b/CLAUDE.md index ff8b75e6..364f5e43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,9 +55,11 @@ flutter test --coverage - Visualisation en temps réel des zones de score ### Placement des impacts -- **Éditeur d'impacts plein écran** : tap pour ajouter, tap sur un impact pour - l'éditer (score/suppression), appui long pour déplacer, pincer pour zoomer -- Le placement est entièrement manuel ; le bouton ↻ de l'écran de plotting +- **Éditeur d'impacts plein écran** : tap pour ajouter (y compris juste à côté + ou par-dessus un impact existant), appui long pour déplacer, pincer pour zoomer +- Un tap n'ouvre jamais d'édition de score : le score reste calculé + automatiquement d'après la position de l'impact +- Le placement est entièrement manuel ; le bouton ↻ de l'écran de synthèse efface tous les impacts sans toucher à la calibration ### Calcul des scores diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart index db19f6ab..a9b0a13e 100644 --- a/lib/features/analysis/analysis_provider.dart +++ b/lib/features/analysis/analysis_provider.dart @@ -135,7 +135,7 @@ class AnalysisProvider extends ChangeNotifier { notifyListeners(); } - /// Efface tous les impacts en un clic (bouton ↻ de l'écran Plotting). + /// Efface tous les impacts en un clic (bouton ↻ de l'écran Synthèse). /// La calibration (centre, rayon, anneaux) n'est pas touchée. void clearShots() { _shots.clear(); diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index a4c86543..347db502 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -1,7 +1,9 @@ -/// Écran principal de Plotting et d'analyse - Interface centrale de traitement des cibles. +/// Écran principal de Synthèse et d'analyse - Interface centrale de traitement des cibles. /// /// Affiche d'abord la calibration de la cible, puis l'overlay des anneaux et impacts détectés. -/// Permet le calcul des scores et statistiques de groupement (Plotting). +/// Permet le calcul des scores et statistiques de groupement (Synthèse), et +/// c'est de là que l'on termine la session. L'ajout des impacts, lui, se fait +/// dans l'éditeur d'impacts plein écran. library; import 'dart:io'; @@ -143,7 +145,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { /// Repasse en mode calibration. /// - /// La cible du mode Plotting est désormais un élément fixe (aucun zoom à + /// La cible du mode Synthèse est désormais un élément fixe (aucun zoom à /// réinitialiser) : on se contente donc de rebasculer l'état. void _enterCalibration() { setState(() { @@ -161,8 +163,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { /// déplacés ou supprimés sont donc immédiatement répercutés ici. /// /// Au retour, quel que soit le résultat (validation OU retour arrière), on - /// revient TOUJOURS sur le Plotting. L'éditeur d'impacts n'est ouvert que - /// depuis le Plotting : il doit donc y ramener, jamais sur la calibration. + /// revient TOUJOURS sur la Synthèse. L'éditeur d'impacts n'est ouvert que + /// depuis la Synthèse : il doit donc y ramener, jamais sur la calibration. Future _openImpactEditor(AnalysisProvider provider) async { await Navigator.of(context).push( MaterialPageRoute( @@ -297,11 +299,11 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ); } - /// Indication affichée sous le titre en mode Plotting. + /// Indication affichée sous le titre en mode Synthèse. /// /// Rien n'indiquait comment ajouter un impact une fois la calibration /// validée : ce rappel pointe vers le geste (toucher la cible). - Widget _buildPlottingHint(AnalysisProvider provider) { + Widget _buildSyntheseHint(AnalysisProvider provider) { final hasShots = provider.shotCount > 0; return Padding( @@ -330,14 +332,14 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { /// Bouton flottant du bas de l'écran. /// - /// En calibration : VALIDER. En plotting : tant qu'aucun impact n'est placé, + /// En calibration : VALIDER. En synthèse : tant qu'aucun impact n'est placé, /// on ne propose pas de terminer la session mais de placer un impact (le /// bouton ouvre l'éditeur, comme un tap sur la cible). Widget _buildBottomAction(BuildContext context, AnalysisProvider provider) { if (_isCalibrating) { return FloatingActionButton.extended( // Même bouton bleu flottant que « TERMINER LA SESSION » : - // on fige la calibration puis on bascule sur le Plotting. + // on fige la calibration puis on bascule sur la Synthèse. onPressed: () { _calibrationKey.currentState?.commitCalibration(); setState(() => _isCalibrating = false); @@ -373,10 +375,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ? sessionProvider.targetCount + 1 : null; - final titlePrefix = _isCalibrating ? 'Calibration' : 'Plotting'; + final titlePrefix = _isCalibrating ? 'Calibration' : 'Synthèse'; final title = targetNumber != null ? '$titlePrefix - Cible $targetNumber' - : (_isCalibrating ? 'Calibration' : 'Plotting du Tir'); + : (_isCalibrating ? 'Calibration' : 'Synthèse du Tir'); return Scaffold( appBar: AppBar( @@ -400,7 +402,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ), ); } else { - // Retour Plotting -> Calibration : on réinitialise le zoom. + // Retour Synthèse -> Calibration : on réinitialise le zoom. _enterCalibration(); } }, @@ -426,7 +428,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { if (_isCalibrating) _buildCalibrationSettings(provider) else - _buildPlottingHint(provider), + _buildSyntheseHint(provider), AspectRatio( aspectRatio: provider.imageAspectRatio, @@ -663,7 +665,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ); } - /// Affichage du plotting en LECTURE SEULE. + /// Affichage de la synthèse en LECTURE SEULE. /// /// La cible est un élément d'écran FIXE : elle ne se déplace pas et ne se /// zoome pas (plus d'InteractiveViewer). Un tap n'importe où sur la cible diff --git a/lib/features/analysis/impact_editor_screen.dart b/lib/features/analysis/impact_editor_screen.dart index ea180928..45dccacf 100644 --- a/lib/features/analysis/impact_editor_screen.dart +++ b/lib/features/analysis/impact_editor_screen.dart @@ -7,8 +7,11 @@ /// scroll vertical ou une transformation parente. /// /// Interactions : -/// - Tap sur zone vide -> ajoute un impact -/// - Tap sur un impact -> ouvre l'édition (score / suppression) +/// - Tap -> ajoute TOUJOURS un impact, même juste à côté +/// (ou par-dessus) un impact existant. Aucun tap +/// n'ouvre d'édition de score : on peut donc +/// placer un impact au pouce près sans être +/// interrompu par une popup. /// - Appui long + glisser -> déplace l'impact /// /// L'état des impacts est partagé avec l'écran d'analyse via le MÊME @@ -24,7 +27,6 @@ import '../../core/theme/app_theme.dart'; import '../../data/models/shot.dart'; import 'analysis_provider.dart'; import 'widgets/target_overlay.dart'; -import 'widgets/shot_details_sheet.dart'; class ImpactEditorScreen extends StatefulWidget { const ImpactEditorScreen({super.key}); @@ -73,8 +75,11 @@ class _ImpactEditorScreenState extends State { } /// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null. + /// + /// Utilisé uniquement par l'appui long (déplacement) : le tap simple, lui, + /// ajoute toujours un impact sans chercher à en sélectionner un. Shot? _hitTestShot(AnalysisProvider provider, Offset rel, - {double tolerance = 0.04}) { + {double tolerance = 0.06}) { Shot? closest; double minDistance = double.infinity; for (final shot in provider.shots) { @@ -100,7 +105,7 @@ class _ImpactEditorScreenState extends State { title: Text('Placement des impacts (${provider.shotCount})'), leading: IconButton( icon: const Icon(Icons.arrow_back), - tooltip: 'Retour au plotting', + tooltip: 'Retour à la synthèse', onPressed: () => Navigator.pop(context, false), ), ), @@ -134,7 +139,7 @@ class _ImpactEditorScreenState extends State { 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', + 'Tap : ajouter un impact • Appui long : déplacer • Pincer : zoomer', style: TextStyle(color: Colors.white70, fontSize: 12), textAlign: TextAlign.center, ), @@ -151,24 +156,20 @@ class _ImpactEditorScreenState extends State { child: Center( child: GestureDetector( behavior: HitTestBehavior.opaque, - // TAP : éditer si on touche un impact, sinon ajouter. + // TAP : ajoute un impact, sans exception. Même collé à un + // impact existant, le tap crée le nouvel impact au lieu + // d'ouvrir l'édition du score. onTapUp: (details) { if (_movingShotId != null) return; final rel = _toImageRelative(details.globalPosition); if (rel == null) return; - - final hit = _hitTestShot(provider, rel); - if (hit != null) { - showShotDetailsSheet(context, provider, hit); - } else { - provider.addShot(rel.dx, rel.dy); - } + 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); + final hit = _hitTestShot(provider, rel); if (hit != null) { setState(() => _movingShotId = hit.id); } @@ -204,10 +205,10 @@ class _ImpactEditorScreenState extends State { 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) => - showShotDetailsSheet(context, provider, shot), + // Aucun onShotTapped : les impacts ne captent plus le + // toucher, tout va au GestureDetector parent qui + // ajoute un impact (y compris pile sur un impact + // existant). ), ), ], diff --git a/lib/features/analysis/widgets/shot_details_sheet.dart b/lib/features/analysis/widgets/shot_details_sheet.dart deleted file mode 100644 index 635f6a62..00000000 --- a/lib/features/analysis/widgets/shot_details_sheet.dart +++ /dev/null @@ -1,85 +0,0 @@ -/// Bottom sheet de détails d'un impact : modification du score et suppression. -/// -/// Partagée entre l'écran d'analyse (consultation du plotting) et l'éditeur -/// d'impacts plein écran, qui opèrent sur le même AnalysisProvider. -library; - -import 'package:flutter/material.dart'; - -import '../../../data/models/shot.dart'; -import '../analysis_provider.dart'; - -void showShotDetailsSheet( - 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), - ), - ), - ), - ], - ), - ], - ), - ), - ); -} diff --git a/lib/features/analysis/widgets/target_overlay.dart b/lib/features/analysis/widgets/target_overlay.dart index 734f7446..b0a10774 100644 --- a/lib/features/analysis/widgets/target_overlay.dart +++ b/lib/features/analysis/widgets/target_overlay.dart @@ -53,7 +53,10 @@ class TargetOverlay extends StatelessWidget { // Désormais : // - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen). // - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites - // zones de tap localisées (deferToChild) placées sur chaque impact. + // zones de tap localisées (deferToChild) placées sur chaque impact — + // et UNIQUEMENT si [onShotTapped] est fourni. Sans callback, aucune zone + // de tap n'est créée : un tap pile sur un impact traverse jusqu'au parent + // au lieu d'être absorbé dans le vide. return IgnorePointer( ignoring: false, child: CustomPaint( @@ -73,6 +76,8 @@ class TargetOverlay extends StatelessWidget { ), child: LayoutBuilder( builder: (context, constraints) { + final onTapped = onShotTapped; + if (onTapped == null) return const SizedBox.expand(); return Stack( children: shots.map((shot) { final x = shot.x * constraints.maxWidth; @@ -89,7 +94,7 @@ class TargetOverlay extends StatelessWidget { // Le reste de la surface reste donc disponible pour le // pinch/pan de l'InteractiveViewer. behavior: HitTestBehavior.deferToChild, - onTap: () => onShotTapped?.call(shot), + onTap: () => onTapped(shot), child: Container( width: tapSize, height: tapSize,