Ajoute un didacticiel joué à la première utilisation : un voile sombre perce un trou de lumière autour de l'élément à découvrir, une main animée mime le geste attendu (tap, appui long, glisser, pincement à deux doigts pour zoomer) et une bulle explique l'étape avec sa progression. - visite d'accueil : nouvelle session, télémétrie, barre de navigation, réglages - visite de l'éditeur d'impacts : ajouter, déplacer, zoomer au pincement, valider - chaque visite n'est jouée qu'une fois (SharedPreferences) - Paramètres > Aide & didacticiel > Revoir le didacticiel : réinitialise tout et relance la visite au retour sur l'écran concerné Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
288 lines
11 KiB
Dart
288 lines
11 KiB
Dart
/// É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 -> 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
|
||
/// 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 '../../services/tutorial_service.dart';
|
||
import '../tutorial/tutorial_coach.dart';
|
||
import '../tutorial/tutorial_step.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;
|
||
|
||
// Clés du didacticiel : zone de travail et bouton de validation.
|
||
final GlobalKey _tutoCanvasKey = GlobalKey();
|
||
final GlobalKey _tutoValidateKey = GlobalKey();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_transformationController.addListener(_onTransformChanged);
|
||
// Première ouverture de l'éditeur : on montre les gestes disponibles.
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
TutorialCoach.maybeStart(
|
||
context,
|
||
tourId: TutorialTours.impactEditor,
|
||
stepsBuilder: _buildEditorTutorialSteps,
|
||
);
|
||
});
|
||
}
|
||
|
||
List<TutorialStep> _buildEditorTutorialSteps() => [
|
||
TutorialStep(
|
||
targetKey: _tutoCanvasKey,
|
||
title: 'Ajouter un impact',
|
||
description:
|
||
'Touchez la cible à l\'endroit de l\'impact : il est ajouté '
|
||
'immédiatement, même collé à un impact déjà placé. Le score est '
|
||
'calculé automatiquement selon la zone touchée.',
|
||
gesture: TutorialGesture.tap,
|
||
icon: Icons.add_location_alt_outlined,
|
||
spotPadding: 0,
|
||
),
|
||
TutorialStep(
|
||
targetKey: _tutoCanvasKey,
|
||
title: 'Déplacer un impact',
|
||
description:
|
||
'Appui long sur un impact, puis glissez le doigt pour l\'ajuster '
|
||
'au millimètre. L\'impact reste visible au-dessus du doigt.',
|
||
gesture: TutorialGesture.drag,
|
||
icon: Icons.open_with,
|
||
spotPadding: 0,
|
||
),
|
||
TutorialStep(
|
||
targetKey: _tutoCanvasKey,
|
||
title: 'Zoomer sur la cible',
|
||
description:
|
||
'Écartez deux doigts pour zoomer (jusqu\'à 12×) et placer vos '
|
||
'impacts avec précision ; rapprochez-les pour dézoomer. À un '
|
||
'doigt, vous faites glisser l\'image.',
|
||
gesture: TutorialGesture.pinch,
|
||
icon: Icons.zoom_in,
|
||
spotPadding: 0,
|
||
),
|
||
TutorialStep(
|
||
targetKey: _tutoValidateKey,
|
||
title: 'Valider vos impacts',
|
||
description:
|
||
'VALIDER renvoie vers la synthèse avec les scores et le '
|
||
'groupement. La corbeille, à gauche, efface tous les impacts '
|
||
'sans toucher à la calibration.',
|
||
gesture: TutorialGesture.tap,
|
||
icon: Icons.check_circle_outline,
|
||
spotPadding: 8,
|
||
),
|
||
];
|
||
|
||
@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.
|
||
///
|
||
/// 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.06}) {
|
||
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 synthèse',
|
||
onPressed: () => Navigator.pop(context, false),
|
||
),
|
||
),
|
||
// Corbeille (efface tous les impacts) + bouton bleu flottant VALIDER.
|
||
// heroTag distinct sur chaque FAB pour éviter le conflit de Hero.
|
||
floatingActionButton: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
FloatingActionButton(
|
||
heroTag: 'reset_impacts',
|
||
onPressed: () => provider.clearShots(),
|
||
backgroundColor: Colors.grey.shade800,
|
||
tooltip: 'Effacer tous les impacts',
|
||
child: const Icon(Icons.delete),
|
||
),
|
||
const SizedBox(width: 12),
|
||
FloatingActionButton.extended(
|
||
key: _tutoValidateKey,
|
||
heroTag: 'validate_impacts',
|
||
onPressed: () => Navigator.pop(context, true),
|
||
backgroundColor: AppTheme.primaryColor,
|
||
icon: const Icon(Icons.check),
|
||
label: const Text('VALIDER'),
|
||
),
|
||
],
|
||
),
|
||
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 un impact • 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(
|
||
key: _tutoCanvasKey,
|
||
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 : 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;
|
||
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);
|
||
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,
|
||
// 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).
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
} |