feat: adding new plotting screen fixing pan and tap

This commit is contained in:
qguillaume
2026-06-09 00:12:10 +02:00
parent 0c509bfdc3
commit 2107f187b6
2 changed files with 362 additions and 160 deletions

View File

@@ -19,6 +19,7 @@ import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
import '../session/session_provider.dart'; import '../session/session_provider.dart';
import 'analysis_provider.dart'; import 'analysis_provider.dart';
import 'impact_editor_screen.dart';
import '../crop/crop_screen.dart'; import '../crop/crop_screen.dart';
import '../capture/capture_screen.dart'; import '../capture/capture_screen.dart';
import 'widgets/target_overlay.dart'; import 'widgets/target_overlay.dart';
@@ -168,6 +169,36 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
setState(() => _isCalibrating = true); 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<void> _openImpactEditor(AnalysisProvider provider) async {
final validated = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.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. /// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
/// ///
/// On privilégie TOUJOURS l'image source non rognée (originalImagePath). /// On privilégie TOUJOURS l'image source non rognée (originalImagePath).
@@ -230,14 +261,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
if (_isCalibrating) if (_isCalibrating)
TextButton( TextButton(
onPressed: () { onPressed: () {
// On fige la calibration courante dans le provider AVANT de // On fige la calibration courante AVANT d'ouvrir l'éditeur,
// basculer en Plotting, pour que l'instance Plotting parte // puis on passe sur l'écran d'édition d'impacts plein écran
// d'un état figé et indépendant des rebuilds de calibration. // (zoom fiable + placement). Le mode Plotting (lecture seule)
// s'affichera au retour si l'utilisateur valide.
_calibrationKey.currentState?.commitCalibration(); _calibrationKey.currentState?.commitCalibration();
setState(() { _openImpactEditor(context.read<AnalysisProvider>());
_isCalibrating = false;
_isSelectingReferences = false;
});
}, },
child: const Text( child: const Text(
'TERMINER', 'TERMINER',
@@ -332,7 +361,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
], ],
) )
: _buildZoomableImageWithOverlay(context, provider), : _buildReadOnlyPlotImage(context, provider),
), ),
if (!_isCalibrating) if (!_isCalibrating)
@@ -340,6 +369,27 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
children: [ 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<AnalysisProvider>()),
),
),
const SizedBox(height: 12),
Card( Card(
color: AppTheme.primaryColor.withValues(alpha: 0.1), color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: ListTile( 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, BuildContext context,
AnalysisProvider provider, 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( return InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
minScale: 1.0, minScale: 1.0,
maxScale: 10.0, maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(80), boundaryMargin: const EdgeInsets.all(80),
panEnabled: _movingShotId == null, panEnabled: true,
child: GestureDetector( child: Stack(
// AJOUT D'IMPACT : géré ici, sur le GestureDetector parent, par un children: [
// simple tap. Un seul détecteur de tap -> plus de couche concurrente Image.file(
// avec l'InteractiveViewer, donc le pinch/zoom redevient fiable. File(provider.imagePath!),
// key: _imageKey,
// Flutter ne déclenche onTapUp que si le doigt n'a pas bougé au-delà fit: BoxFit.contain,
// du seuil (touch slop) : un déplacement part en pan via ),
// l'InteractiveViewer, un toucher bref pose un impact. Positioned.fill(
onTapUp: (TapUpDetails details) { child: TargetOverlay(
// 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(
targetCenterX: provider.targetCenterX, targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY, targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius, targetRadius: provider.targetRadius,
@@ -672,13 +591,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
shots: provider.shots, shots: provider.shots,
showRings: true, showRings: true,
zoomScale: _currentZoomScale, zoomScale: _currentZoomScale,
// Lecture seule : tap sur impact -> détails (consultation).
onShotTapped: (shot) => onShotTapped: (shot) =>
_showShotDetails(context, provider, shot), _showShotDetails(context, provider, shot),
// onAddShot retiré : l'ajout est géré par le GestureDetector
// parent (onTapUp) pour ne pas concurrencer le pinch/zoom.
), ),
], ),
), ],
), ),
); );
} }

View File

@@ -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<ImpactEditorScreen> createState() => _ImpactEditorScreenState();
}
class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
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<AnalysisProvider>();
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<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),
),
),
),
],
),
],
),
),
);
}
}