feat: tap simple toujours createur d'impact + ecran de plotting renomme Synthese
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
provider.addShot(rel.dx, rel.dy);
|
||||||
final hit = _hitTestShot(provider, rel);
|
|
||||||
if (hit != null) {
|
|
||||||
showShotDetailsSheet(context, provider, hit);
|
|
||||||
} else {
|
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user