feat: tap simple toujours createur d'impact + ecran de plotting renomme Synthese

This commit is contained in:
qlionbleusam
2026-08-07 12:04:49 +02:00
parent 86abb645ce
commit 3812cd740b
6 changed files with 49 additions and 124 deletions
+5 -3
View File
@@ -55,9 +55,11 @@ flutter test --coverage
- Visualisation en temps réel des zones de score - Visualisation en temps réel des zones de score
### Placement des impacts ### Placement des impacts
- **Éditeur d'impacts plein écran** : tap pour ajouter, tap sur un impact pour - **Éditeur d'impacts plein écran** : tap pour ajouter (y compris juste à côté
l'éditer (score/suppression), appui long pour déplacer, pincer pour zoomer ou par-dessus un impact existant), appui long pour déplacer, pincer pour zoomer
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de plotting - 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 efface tous les impacts sans toucher à la calibration
### Calcul des scores ### Calcul des scores
+1 -1
View File
@@ -135,7 +135,7 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); 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. /// La calibration (centre, rayon, anneaux) n'est pas touchée.
void clearShots() { void clearShots() {
_shots.clear(); _shots.clear();
+16 -14
View File
@@ -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. /// 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; library;
import 'dart:io'; import 'dart:io';
@@ -143,7 +145,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
/// Repasse en mode calibration. /// 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. /// réinitialiser) : on se contente donc de rebasculer l'état.
void _enterCalibration() { void _enterCalibration() {
setState(() { setState(() {
@@ -161,8 +163,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
/// déplacés ou supprimés sont donc immédiatement répercutés ici. /// 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 /// 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 /// revient TOUJOURS sur la Synthèse. L'éditeur d'impacts n'est ouvert que
/// depuis le Plotting : il doit donc y ramener, jamais sur la calibration. /// depuis la Synthèse : il doit donc y ramener, jamais sur la calibration.
Future<void> _openImpactEditor(AnalysisProvider provider) async { Future<void> _openImpactEditor(AnalysisProvider provider) async {
await Navigator.of(context).push<bool>( await Navigator.of(context).push<bool>(
MaterialPageRoute( 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 /// Rien n'indiquait comment ajouter un impact une fois la calibration
/// validée : ce rappel pointe vers le geste (toucher la cible). /// validée : ce rappel pointe vers le geste (toucher la cible).
Widget _buildPlottingHint(AnalysisProvider provider) { Widget _buildSyntheseHint(AnalysisProvider provider) {
final hasShots = provider.shotCount > 0; final hasShots = provider.shotCount > 0;
return Padding( return Padding(
@@ -330,14 +332,14 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
/// Bouton flottant du bas de l'écran. /// 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 /// on ne propose pas de terminer la session mais de placer un impact (le
/// bouton ouvre l'éditeur, comme un tap sur la cible). /// bouton ouvre l'éditeur, comme un tap sur la cible).
Widget _buildBottomAction(BuildContext context, AnalysisProvider provider) { Widget _buildBottomAction(BuildContext context, AnalysisProvider provider) {
if (_isCalibrating) { if (_isCalibrating) {
return FloatingActionButton.extended( return FloatingActionButton.extended(
// Même bouton bleu flottant que « TERMINER LA SESSION » : // 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: () { onPressed: () {
_calibrationKey.currentState?.commitCalibration(); _calibrationKey.currentState?.commitCalibration();
setState(() => _isCalibrating = false); setState(() => _isCalibrating = false);
@@ -373,10 +375,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
? sessionProvider.targetCount + 1 ? sessionProvider.targetCount + 1
: null; : null;
final titlePrefix = _isCalibrating ? 'Calibration' : 'Plotting'; final titlePrefix = _isCalibrating ? 'Calibration' : 'Synthèse';
final title = targetNumber != null final title = targetNumber != null
? '$titlePrefix - Cible $targetNumber' ? '$titlePrefix - Cible $targetNumber'
: (_isCalibrating ? 'Calibration' : 'Plotting du Tir'); : (_isCalibrating ? 'Calibration' : 'Synthèse du Tir');
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -400,7 +402,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
); );
} else { } else {
// Retour Plotting -> Calibration : on réinitialise le zoom. // Retour Synthèse -> Calibration : on réinitialise le zoom.
_enterCalibration(); _enterCalibration();
} }
}, },
@@ -426,7 +428,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
if (_isCalibrating) if (_isCalibrating)
_buildCalibrationSettings(provider) _buildCalibrationSettings(provider)
else else
_buildPlottingHint(provider), _buildSyntheseHint(provider),
AspectRatio( AspectRatio(
aspectRatio: provider.imageAspectRatio, 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 /// 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 /// zoome pas (plus d'InteractiveViewer). Un tap n'importe où sur la cible
+19 -18
View File
@@ -7,8 +7,11 @@
/// scroll vertical ou une transformation parente. /// scroll vertical ou une transformation parente.
/// ///
/// Interactions : /// Interactions :
/// - Tap sur zone vide -> ajoute un impact /// - Tap -> ajoute TOUJOURS un impact, même juste à côté
/// - Tap sur un impact -> ouvre l'édition (score / suppression) /// (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 /// - Appui long + glisser -> déplace l'impact
/// ///
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME /// 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 '../../data/models/shot.dart';
import 'analysis_provider.dart'; import 'analysis_provider.dart';
import 'widgets/target_overlay.dart'; import 'widgets/target_overlay.dart';
import 'widgets/shot_details_sheet.dart';
class ImpactEditorScreen extends StatefulWidget { class ImpactEditorScreen extends StatefulWidget {
const ImpactEditorScreen({super.key}); const ImpactEditorScreen({super.key});
@@ -73,8 +75,11 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
} }
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null. /// 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, Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
{double tolerance = 0.04}) { {double tolerance = 0.06}) {
Shot? closest; Shot? closest;
double minDistance = double.infinity; double minDistance = double.infinity;
for (final shot in provider.shots) { for (final shot in provider.shots) {
@@ -100,7 +105,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
title: Text('Placement des impacts (${provider.shotCount})'), title: Text('Placement des impacts (${provider.shotCount})'),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
tooltip: 'Retour au plotting', tooltip: 'Retour à la synthèse',
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
), ),
), ),
@@ -134,7 +139,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
color: Colors.white10, color: Colors.white10,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text( 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), style: TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -151,24 +156,20 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
child: Center( child: Center(
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, 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) { onTapUp: (details) {
if (_movingShotId != null) return; if (_movingShotId != null) return;
final rel = _toImageRelative(details.globalPosition); final rel = _toImageRelative(details.globalPosition);
if (rel == null) return; 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. // APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
onLongPressStart: (details) { onLongPressStart: (details) {
final rel = _toImageRelative(details.globalPosition); final rel = _toImageRelative(details.globalPosition);
if (rel == null) return; if (rel == null) return;
final hit = _hitTestShot(provider, rel, tolerance: 0.06); final hit = _hitTestShot(provider, rel);
if (hit != null) { if (hit != null) {
setState(() => _movingShotId = hit.id); setState(() => _movingShotId = hit.id);
} }
@@ -204,10 +205,10 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
shots: provider.shots, shots: provider.shots,
showRings: true, showRings: true,
zoomScale: _currentZoomScale, zoomScale: _currentZoomScale,
// L'ajout et la sélection sont gérés par le // Aucun onShotTapped : les impacts ne captent plus le
// GestureDetector parent ci-dessus. // toucher, tout va au GestureDetector parent qui
onShotTapped: (shot) => // ajoute un impact (y compris pile sur un impact
showShotDetailsSheet(context, provider, shot), // existant).
), ),
), ),
], ],
@@ -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<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),
),
),
),
],
),
],
),
),
);
}
@@ -53,7 +53,10 @@ class TargetOverlay extends StatelessWidget {
// Désormais : // Désormais :
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen). // - 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 // - 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( return IgnorePointer(
ignoring: false, ignoring: false,
child: CustomPaint( child: CustomPaint(
@@ -73,6 +76,8 @@ class TargetOverlay extends StatelessWidget {
), ),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final onTapped = onShotTapped;
if (onTapped == null) return const SizedBox.expand();
return Stack( return Stack(
children: shots.map((shot) { children: shots.map((shot) {
final x = shot.x * constraints.maxWidth; final x = shot.x * constraints.maxWidth;
@@ -89,7 +94,7 @@ class TargetOverlay extends StatelessWidget {
// Le reste de la surface reste donc disponible pour le // Le reste de la surface reste donc disponible pour le
// pinch/pan de l'InteractiveViewer. // pinch/pan de l'InteractiveViewer.
behavior: HitTestBehavior.deferToChild, behavior: HitTestBehavior.deferToChild,
onTap: () => onShotTapped?.call(shot), onTap: () => onTapped(shot),
child: Container( child: Container(
width: tapSize, width: tapSize,
height: tapSize, height: tapSize,