fix(calibration): application de la rotation et du centrage sur l'image globale

This commit is contained in:
qguillaume
2026-05-27 00:23:48 +02:00
parent 02c0d449e4
commit e570bd4296
2 changed files with 45 additions and 154 deletions

View File

@@ -8,6 +8,7 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
@@ -22,17 +23,20 @@ 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';
import 'widgets/target_calibration.dart'; import 'widgets/target_calibration.dart';
import 'widgets/target_overlay.dart';
import 'widgets/score_card.dart'; import 'widgets/score_card.dart';
import 'widgets/grouping_stats.dart'; import 'widgets/grouping_stats.dart';
import '../capture/capture_screen.dart'; // Si ton écran principal s'appelle comme ça
import '../crop/crop_screen.dart';
class AnalysisScreen extends StatelessWidget { class AnalysisScreen extends StatelessWidget {
final String imagePath; final String imagePath;
final TargetType targetType; final TargetType targetType;
// CORRECTION : Utilisation de coordonnées directes pour effacer l'erreur rouge
final double? initialCenterX; final double? initialCenterX;
final double? initialCenterY; final double? initialCenterY;
final double? cropScale; final double? cropScale;
final Offset? cropOffset; final Offset? cropOffset;
final double? cropRotation; // Reçu proprement depuis le CropScreen
const AnalysisScreen({ const AnalysisScreen({
super.key, super.key,
@@ -42,11 +46,12 @@ class AnalysisScreen extends StatelessWidget {
this.initialCenterY, this.initialCenterY,
this.cropScale, this.cropScale,
this.cropOffset, this.cropOffset,
this.cropRotation,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Reconstitution de l'Offset si présent pour le provider en arrière-plan // Reconstitution de l'Offset pour le traitement métier en arrière-plan
final manualCenterOffset = (initialCenterX != null && initialCenterY != null) final manualCenterOffset = (initialCenterX != null && initialCenterY != null)
? Offset(initialCenterX!, initialCenterY!) ? Offset(initialCenterX!, initialCenterY!)
: null; : null;
@@ -61,6 +66,7 @@ class AnalysisScreen extends StatelessWidget {
child: _AnalysisScreenContent( child: _AnalysisScreenContent(
cropScale: cropScale, cropScale: cropScale,
cropOffset: cropOffset, cropOffset: cropOffset,
cropRotation: cropRotation, // Envoyé à la structure d'affichage
), ),
); );
} }
@@ -69,10 +75,12 @@ class AnalysisScreen extends StatelessWidget {
class _AnalysisScreenContent extends StatefulWidget { class _AnalysisScreenContent extends StatefulWidget {
final double? cropScale; final double? cropScale;
final Offset? cropOffset; final Offset? cropOffset;
final double? cropRotation;
const _AnalysisScreenContent({ const _AnalysisScreenContent({
this.cropScale, this.cropScale,
this.cropOffset, this.cropOffset,
this.cropRotation,
}); });
@override @override
@@ -82,17 +90,15 @@ class _AnalysisScreenContent extends StatefulWidget {
class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
final GlobalKey<TargetCalibrationState> _calibrationKey = GlobalKey<TargetCalibrationState>(); final GlobalKey<TargetCalibrationState> _calibrationKey = GlobalKey<TargetCalibrationState>();
// CORRECTION : Forcé à TRUE pour arriver directement sur la calibration après le Crop ! // Forcé à TRUE pour démarrer sur l'ajustement des cercles
bool _isCalibrating = true; bool _isCalibrating = true;
bool _isSelectingReferences = false; bool _isSelectingReferences = false;
bool _isAtBottom = false; bool _isAtBottom = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TransformationController _transformationController = TransformationController(); final TransformationController _transformationController = TransformationController();
final GlobalKey _imageKey = GlobalKey(); final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0; double _currentZoomScale = 1.0;
// AJOUT : Stockage de l'ID du tir en cours de déplacement par appui long
String? _movingShotId; String? _movingShotId;
@override @override
@@ -104,9 +110,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
void _onScroll() { void _onScroll() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final isBottom = final isBottom = _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 20;
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 20;
if (isBottom != _isAtBottom) { if (isBottom != _isAtBottom) {
setState(() { setState(() {
_isAtBottom = isBottom; _isAtBottom = isBottom;
@@ -138,9 +142,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
final sessionProvider = context.watch<SessionProvider>(); final sessionProvider = context.watch<SessionProvider>();
final targetNumber = sessionProvider.isSessionActive ? sessionProvider.targetCount + 1 : null; final targetNumber = sessionProvider.isSessionActive ? sessionProvider.targetCount + 1 : null;
// CORRECTION : Remplacement du mot "Analyse" par "Plotting"
final titlePrefix = _isCalibrating ? 'Calibration' : 'Plotting'; final titlePrefix = _isCalibrating ? 'Calibration' : 'Plotting';
final title = targetNumber != null ? '$titlePrefix - Cible $targetNumber' : ( _isCalibrating ? 'Calibration' : 'Plotting du Tir'); final title = targetNumber != null ? '$titlePrefix - Cible $targetNumber' : (_isCalibrating ? 'Calibration' : 'Plotting du Tir');
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -149,7 +152,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () { onPressed: () {
if (_isCalibrating) { if (_isCalibrating) {
// Si on fait retour pendant la calibration, on retourne sagement au CropScreen
final provider = context.read<AnalysisProvider>(); final provider = context.read<AnalysisProvider>();
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
@@ -163,7 +165,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
); );
} else { } else {
// Si on fait retour depuis le Plotting, on revient en arrière dans la calibration
setState(() => _isCalibrating = true); setState(() => _isCalibrating = true);
} }
}, },
@@ -176,7 +177,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
if (_isCalibrating) if (_isCalibrating)
TextButton( TextButton(
onPressed: () => setState(() => _isCalibrating = false), // Dévoile le Plotting Screen onPressed: () => setState(() => _isCalibrating = false),
child: const Text( child: const Text(
'TERMINER', 'TERMINER',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
@@ -186,12 +187,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
body: Stack( body: Stack(
children: [ children: [
Navigator.canPop(context) ? const SizedBox.shrink() : const SizedBox.shrink(),
SingleChildScrollView( SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
child: Column( child: Column(
children: [ children: [
// Header de chargement
Padding( Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
@@ -208,64 +207,34 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
), ),
// Zone centrale d'affichage : Calibration OU Plotting interactif // SECTION CORRIGÉE : Utilise maintenant widget.cropRotation sans aucune erreur !
AspectRatio( AspectRatio(
aspectRatio: provider.imageAspectRatio, aspectRatio: provider.imageAspectRatio,
child: _isCalibrating child: _isCalibrating
? Stack( ? Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// 1. L'IMAGE ZOOME ET RECADRE
ClipRect( ClipRect(
child: Builder( child: Builder(
builder: (context) { builder: (context) {
const double _currentRotation = 0.0; final double rotationAngle = widget.cropRotation ?? 0.0;
return Transform( return Transform(
transform: Matrix4.identity() transform: Matrix4.identity()
..translate(widget.cropOffset?.dx ?? 0.0, widget.cropOffset?.dy ?? 0.0) ..translate(widget.cropOffset?.dx ?? 0.0, widget.cropOffset?.dy ?? 0.0)
..scale(widget.cropScale ?? 1.0, widget.cropScale ?? 1.0), ..scale(1.0, 1.0), // Zoom ignoré au plotting pour rester en vue globale
alignment: Alignment.center, alignment: Alignment.center,
child: Transform.rotate(
angle: rotationAngle * (math.pi / 180),
child: Image.file( child: Image.file(
File(provider.imagePath!), File(provider.imagePath!),
fit: BoxFit.contain, // Respecte la géométrie du CropScreen fit: BoxFit.contain,
),
), ),
); );
} }
), ),
), ),
// 2. LE MASQUE OPAQUE (Cache tout ce qui dépasse pour simuler le Crop parfait)
Positioned.fill(
child: IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
// On recalcule la taille du carré de calibration (95% du plus petit côté, comme l'écran d'avant)
final double cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95;
return ColorFiltered(
colorFilter: const ColorFilter.mode(
Color(0xFF101214), // La couleur sombre de ton fond d'écran
BlendMode.srcOut,
),
child: Stack(
children: [
Center(
child: Container(
width: cropSize,
height: cropSize,
color: Colors.black, // Fenêtre visible carrée aux coins droits
),
),
],
),
);
},
),
),
),
// 3. LA CIBLE ROUGE DE CALIBRATION (Reste au-dessus, parfaitement accessible)
TargetCalibration( TargetCalibration(
key: _calibrationKey, key: _calibrationKey,
initialCenterX: provider.targetCenterX, initialCenterX: provider.targetCenterX,
@@ -275,15 +244,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
initialRingCount: provider.ringCount, initialRingCount: provider.ringCount,
initialRingRadii: provider.ringRadii, initialRingRadii: provider.ringRadii,
targetType: provider.targetType!, targetType: provider.targetType!,
onCalibrationChanged: onCalibrationChanged: (centerX, centerY, innerRadius, radius, ringCount, {ringRadii}) {
(
centerX,
centerY,
innerRadius,
radius,
ringCount, {
List<double>? ringRadii,
}) {
provider.adjustTargetPosition( provider.adjustTargetPosition(
centerX, centerX,
centerY, centerY,
@@ -299,34 +260,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
: _buildZoomableImageWithOverlay(context, provider), : _buildZoomableImageWithOverlay(context, provider),
), ),
// Vue inférieure : Cartes de scores (Plotting) OU Flèches de réglages (Calibration)
if (!_isCalibrating) if (!_isCalibrating)
Padding( Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
children: [ children: [
// Petit bouton pour réajuster la calibration si besoin
Card( Card(
color: AppTheme.primaryColor.withValues(alpha: 0.1), color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: ListTile( child: ListTile(
leading: const Icon( leading: const Icon(Icons.tune, color: AppTheme.primaryColor),
Icons.tune,
color: AppTheme.primaryColor,
),
title: const Text('Ajuster la calibration'), title: const Text('Ajuster la calibration'),
subtitle: const Text( subtitle: const Text('Modifier le centre ou le rayon global'),
'Modifier le centre ou le rayon global', trailing: const Icon(Icons.arrow_forward_ios, size: 16),
),
trailing: const Icon(
Icons.arrow_forward_ios,
size: 16,
),
onTap: () => setState(() => _isCalibrating = true), onTap: () => setState(() => _isCalibrating = true),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Score card
ScoreCard( ScoreCard(
totalScore: provider.totalScore, totalScore: provider.totalScore,
shotCount: provider.shotCount, shotCount: provider.shotCount,
@@ -334,16 +283,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
targetType: provider.targetType!, targetType: provider.targetType!,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (provider.groupingResult != null && provider.shotCount > 1)
// Grouping stats
if (provider.groupingResult != null &&
provider.shotCount > 1)
GroupingStats( GroupingStats(
groupingResult: provider.groupingResult!, groupingResult: provider.groupingResult!,
targetCenterX: provider.targetCenterX, targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY, targetCenterY: provider.targetCenterY,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildActionButtons(context, provider), _buildActionButtons(context, provider),
const SizedBox(height: 50), const SizedBox(height: 50),
@@ -351,18 +296,13 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
) )
else else
// Mode Calibration actif : On affiche les flèches de micro-ajustement
Padding( Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
children: [ children: [
const Text( const Text(
'Ajustement precis (pixel par pixel)', 'Ajustement precis (pixel par pixel)',
style: TextStyle( style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white70),
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Center( Center(
@@ -375,7 +315,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -384,24 +323,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
children: [ children: [
const Text( const Text(
'Instructions de calibration', 'Instructions de calibration',
style: TextStyle( style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
fontWeight: FontWeight.bold,
fontSize: 16,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildInstructionItem( _buildInstructionItem(Icons.open_with, 'Glissez le centre pour positionner le centre de la cible'),
Icons.open_with, _buildInstructionItem(Icons.zoom_out_map, 'Pincez l\'ecran a 2 doigts ou utilisez la jauge pour la taille'),
'Glissez le centre pour positionner le centre de la cible', _buildInstructionItem(Icons.visibility, 'Appuyez sur TERMINER en haut a droite pour valider'),
),
_buildInstructionItem(
Icons.zoom_out_map,
'Pincez l\'ecran a 2 doigts ou utilisez la jauge pour la taille',
),
_buildInstructionItem(
Icons.visibility,
'Appuyez sur TERMINER en haut a droite pour valider',
),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [
@@ -436,13 +363,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
left: 0, left: 0,
right: 0, right: 0,
child: Align( child: Align(
alignment: _isAtBottom alignment: _isAtBottom ? Alignment.bottomCenter : Alignment.bottomRight,
? Alignment.bottomCenter
: Alignment.bottomRight,
child: Padding( child: Padding(
padding: _isAtBottom padding: _isAtBottom ? EdgeInsets.zero : const EdgeInsets.all(16.0),
? EdgeInsets.zero
: const EdgeInsets.all(16.0),
child: _isCalibrating child: _isCalibrating
? const SizedBox.shrink() ? const SizedBox.shrink()
: FloatingActionButton.extended( : FloatingActionButton.extended(
@@ -466,30 +389,20 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
children: [ children: [
Icon(icon, size: 16, color: AppTheme.primaryColor), Icon(icon, size: 16, color: AppTheme.primaryColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
child: Text(
text,
style: const TextStyle(fontSize: 13),
),
),
], ],
), ),
); );
} }
Widget _buildZoomableImageWithOverlay( Widget _buildZoomableImageWithOverlay(BuildContext context, AnalysisProvider provider) {
BuildContext context,
AnalysisProvider provider,
) {
return InteractiveViewer( return InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
minScale: 1.0, minScale: 1.0,
maxScale: 10.0, maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(double.infinity), boundaryMargin: const EdgeInsets.all(double.infinity),
// Désactive le pan à deux doigts quand on déplace un impact pour éviter les tremblements d'image
panEnabled: _movingShotId == null, panEnabled: _movingShotId == null,
child: GestureDetector( child: GestureDetector(
// 1. CAPTURE DU DOUBLE-TAP (Ouverture directe de l'édition)
onDoubleTapDown: (TapDownDetails details) { onDoubleTapDown: (TapDownDetails details) {
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return; if (box == null) return;
@@ -519,8 +432,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
_showShotDetails(context, provider, closestShot); _showShotDetails(context, provider, closestShot);
} }
}, },
// 2. APPUI LONG : Début de sélection de l'impact à déplacer
onLongPressStart: (LongPressStartDetails details) { onLongPressStart: (LongPressStartDetails details) {
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return; if (box == null) return;
@@ -533,7 +444,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Shot? closestShot; Shot? closestShot;
double minDistance = double.infinity; double minDistance = double.infinity;
const double dragTolerance = 0.06; // Tolérance d'accroche un peu plus large pour le doigt const double dragTolerance = 0.06;
for (final shot in provider.shots) { for (final shot in provider.shots) {
final dx = shot.x - relX; final dx = shot.x - relX;
@@ -546,41 +457,30 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} }
} }
// Si on trouve un impact, on active un petit vibreur (si dispo de base) et on verrouille son ID
if (closestShot != null) { if (closestShot != null) {
setState(() { setState(() {
_movingShotId = closestShot!.id; _movingShotId = closestShot!.id;
}); });
} }
}, },
// 3. GLISSER : On déplace l'impact verrouillé en temps réel avec un décalage visuel
onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) { onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) {
if (_movingShotId == null) return; if (_movingShotId == null) return;
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return; if (box == null) return;
// CORRECTION : On applique un décalage de pixels (Offset) vers le haut et la gauche
// -25 pixels sur l'axe X (vers la gauche) et -35 pixels sur l'axe Y (vers le haut)
// pour que l'impact sorte de dessous la pulpe du doigt !
final adjustedGlobalPosition = details.globalPosition + const Offset(-25, -35); final adjustedGlobalPosition = details.globalPosition + const Offset(-25, -35);
final localOffset = box.globalToLocal(adjustedGlobalPosition); final localOffset = box.globalToLocal(adjustedGlobalPosition);
// Calcul des coordonnées relatives bridées entre 0.0 et 1.0 (pour pas sortir de la photo)
final relX = (localOffset.dx / box.size.width).clamp(0.0, 1.0); final relX = (localOffset.dx / box.size.width).clamp(0.0, 1.0);
final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0); final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0);
// On met à jour directement la position dans le state global via le provider
provider.updateShotPosition(_movingShotId!, relX, relY); provider.updateShotPosition(_movingShotId!, relX, relY);
}, },
// 4. RELÂCHER : Fin du déplacement de l'impact
onLongPressEnd: (_) { onLongPressEnd: (_) {
if (_movingShotId != null) { if (_movingShotId != null) {
setState(() { setState(() {
_movingShotId = null; // Libère le verrou _movingShotId = null;
}); });
} }
}, },
@@ -609,20 +509,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} }
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) { Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
return const Column( return const Column(children: [Row(children: [])]);
children: [
Row(
children: [],
),
],
);
} }
void _showShotDetails( void _showShotDetails(BuildContext context, AnalysisProvider provider, Shot shot) {
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => Container( builder: (context) => Container(

View File

@@ -386,8 +386,9 @@ class _CropScreenState extends State<CropScreen> {
targetType: widget.targetType, targetType: widget.targetType,
initialCenterX: targetCenterX, initialCenterX: targetCenterX,
initialCenterY: targetCenterY, initialCenterY: targetCenterY,
cropScale: _scale, // On passe bien le zoom mémorisé cropScale: _scale,
cropOffset: _offset, // On passe bien le décalage mémorisé cropOffset: _offset,
cropRotation: _rotation, // <-- On envoie la rotation ici !
), ),
), ),
); );