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 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
@@ -22,17 +23,20 @@ import '../crop/crop_screen.dart';
import '../capture/capture_screen.dart';
import 'widgets/target_overlay.dart';
import 'widgets/target_calibration.dart';
import 'widgets/target_overlay.dart';
import 'widgets/score_card.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 {
final String imagePath;
final TargetType targetType;
// CORRECTION : Utilisation de coordonnées directes pour effacer l'erreur rouge
final double? initialCenterX;
final double? initialCenterY;
final double? cropScale;
final Offset? cropOffset;
final double? cropRotation; // Reçu proprement depuis le CropScreen
const AnalysisScreen({
super.key,
@@ -42,11 +46,12 @@ class AnalysisScreen extends StatelessWidget {
this.initialCenterY,
this.cropScale,
this.cropOffset,
this.cropRotation,
});
@override
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)
? Offset(initialCenterX!, initialCenterY!)
: null;
@@ -61,6 +66,7 @@ class AnalysisScreen extends StatelessWidget {
child: _AnalysisScreenContent(
cropScale: cropScale,
cropOffset: cropOffset,
cropRotation: cropRotation, // Envoyé à la structure d'affichage
),
);
}
@@ -69,10 +75,12 @@ class AnalysisScreen extends StatelessWidget {
class _AnalysisScreenContent extends StatefulWidget {
final double? cropScale;
final Offset? cropOffset;
final double? cropRotation;
const _AnalysisScreenContent({
this.cropScale,
this.cropOffset,
this.cropRotation,
});
@override
@@ -82,17 +90,15 @@ class _AnalysisScreenContent extends StatefulWidget {
class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
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 _isSelectingReferences = false;
bool _isAtBottom = false;
final ScrollController _scrollController = ScrollController();
final TransformationController _transformationController = TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
// AJOUT : Stockage de l'ID du tir en cours de déplacement par appui long
String? _movingShotId;
@override
@@ -104,9 +110,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
void _onScroll() {
if (!_scrollController.hasClients) return;
final isBottom =
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 20;
final isBottom = _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 20;
if (isBottom != _isAtBottom) {
setState(() {
_isAtBottom = isBottom;
@@ -138,9 +142,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
final sessionProvider = context.watch<SessionProvider>();
final targetNumber = sessionProvider.isSessionActive ? sessionProvider.targetCount + 1 : null;
// CORRECTION : Remplacement du mot "Analyse" par "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(
appBar: AppBar(
@@ -149,7 +152,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (_isCalibrating) {
// Si on fait retour pendant la calibration, on retourne sagement au CropScreen
final provider = context.read<AnalysisProvider>();
Navigator.pushReplacement(
context,
@@ -163,7 +165,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
);
} else {
// Si on fait retour depuis le Plotting, on revient en arrière dans la calibration
setState(() => _isCalibrating = true);
}
},
@@ -176,7 +177,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
if (_isCalibrating)
TextButton(
onPressed: () => setState(() => _isCalibrating = false), // Dévoile le Plotting Screen
onPressed: () => setState(() => _isCalibrating = false),
child: const Text(
'TERMINER',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
@@ -186,12 +187,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
body: Stack(
children: [
Navigator.canPop(context) ? const SizedBox.shrink() : const SizedBox.shrink(),
SingleChildScrollView(
controller: _scrollController,
child: Column(
children: [
// Header de chargement
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
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: provider.imageAspectRatio,
child: _isCalibrating
? Stack(
fit: StackFit.expand,
children: [
// 1. L'IMAGE ZOOME ET RECADRE
ClipRect(
child: Builder(
builder: (context) {
const double _currentRotation = 0.0;
final double rotationAngle = widget.cropRotation ?? 0.0;
return Transform(
transform: Matrix4.identity()
..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,
child: Image.file(
File(provider.imagePath!),
fit: BoxFit.contain, // Respecte la géométrie du CropScreen
child: Transform.rotate(
angle: rotationAngle * (math.pi / 180),
child: Image.file(
File(provider.imagePath!),
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(
key: _calibrationKey,
initialCenterX: provider.targetCenterX,
@@ -275,15 +244,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
initialRingCount: provider.ringCount,
initialRingRadii: provider.ringRadii,
targetType: provider.targetType!,
onCalibrationChanged:
(
centerX,
centerY,
innerRadius,
radius,
ringCount, {
List<double>? ringRadii,
}) {
onCalibrationChanged: (centerX, centerY, innerRadius, radius, ringCount, {ringRadii}) {
provider.adjustTargetPosition(
centerX,
centerY,
@@ -299,34 +260,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
: _buildZoomableImageWithOverlay(context, provider),
),
// Vue inférieure : Cartes de scores (Plotting) OU Flèches de réglages (Calibration)
if (!_isCalibrating)
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
// Petit bouton pour réajuster la calibration si besoin
Card(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: ListTile(
leading: const Icon(
Icons.tune,
color: AppTheme.primaryColor,
),
leading: const Icon(Icons.tune, color: AppTheme.primaryColor),
title: const Text('Ajuster la calibration'),
subtitle: const Text(
'Modifier le centre ou le rayon global',
),
trailing: const Icon(
Icons.arrow_forward_ios,
size: 16,
),
subtitle: const Text('Modifier le centre ou le rayon global'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () => setState(() => _isCalibrating = true),
),
),
const SizedBox(height: 12),
// Score card
ScoreCard(
totalScore: provider.totalScore,
shotCount: provider.shotCount,
@@ -334,16 +283,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
targetType: provider.targetType!,
),
const SizedBox(height: 12),
// Grouping stats
if (provider.groupingResult != null &&
provider.shotCount > 1)
if (provider.groupingResult != null && provider.shotCount > 1)
GroupingStats(
groupingResult: provider.groupingResult!,
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
),
const SizedBox(height: 12),
_buildActionButtons(context, provider),
const SizedBox(height: 50),
@@ -351,18 +296,13 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
)
else
// Mode Calibration actif : On affiche les flèches de micro-ajustement
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
const Text(
'Ajustement precis (pixel par pixel)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white70),
),
const SizedBox(height: 8),
Center(
@@ -375,7 +315,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -384,24 +323,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
children: [
const Text(
'Instructions de calibration',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 12),
_buildInstructionItem(
Icons.open_with,
'Glissez le centre pour positionner le centre de la cible',
),
_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',
),
_buildInstructionItem(Icons.open_with, 'Glissez le centre pour positionner le centre de la cible'),
_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),
Row(
children: [
@@ -436,13 +363,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
left: 0,
right: 0,
child: Align(
alignment: _isAtBottom
? Alignment.bottomCenter
: Alignment.bottomRight,
alignment: _isAtBottom ? Alignment.bottomCenter : Alignment.bottomRight,
child: Padding(
padding: _isAtBottom
? EdgeInsets.zero
: const EdgeInsets.all(16.0),
padding: _isAtBottom ? EdgeInsets.zero : const EdgeInsets.all(16.0),
child: _isCalibrating
? const SizedBox.shrink()
: FloatingActionButton.extended(
@@ -466,30 +389,20 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
children: [
Icon(icon, size: 16, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 13),
),
),
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
],
),
);
}
Widget _buildZoomableImageWithOverlay(
BuildContext context,
AnalysisProvider provider,
) {
Widget _buildZoomableImageWithOverlay(BuildContext context, AnalysisProvider provider) {
return InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 10.0,
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,
child: GestureDetector(
// 1. CAPTURE DU DOUBLE-TAP (Ouverture directe de l'édition)
onDoubleTapDown: (TapDownDetails details) {
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
@@ -519,8 +432,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
_showShotDetails(context, provider, closestShot);
}
},
// 2. APPUI LONG : Début de sélection de l'impact à déplacer
onLongPressStart: (LongPressStartDetails details) {
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
@@ -533,7 +444,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Shot? closestShot;
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) {
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) {
setState(() {
_movingShotId = closestShot!.id;
});
}
},
// 3. GLISSER : On déplace l'impact verrouillé en temps réel avec un décalage visuel
onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) {
if (_movingShotId == null) return;
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
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 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 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);
},
// 4. RELÂCHER : Fin du déplacement de l'impact
onLongPressEnd: (_) {
if (_movingShotId != null) {
setState(() {
_movingShotId = null; // Libère le verrou
_movingShotId = null;
});
}
},
@@ -609,20 +509,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
}
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
return const Column(
children: [
Row(
children: [],
),
],
);
return const Column(children: [Row(children: [])]);
}
void _showShotDetails(
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
void _showShotDetails(BuildContext context, AnalysisProvider provider, Shot shot) {
showModalBottomSheet(
context: context,
builder: (context) => Container(