fix(analysis): normaliser les coordonnées de la cible pour annuler l'effet du zoom initial

This commit is contained in:
qguillaume
2026-06-04 23:32:38 +02:00
parent a5d0251545
commit 9e0427f750
2 changed files with 188 additions and 70 deletions

View File

@@ -162,8 +162,10 @@ class AnalysisProvider extends ChangeNotifier {
return;
}
// CORRECTION PARALLÈLE : Changement de la méthode pour l'Isolate asynchrone
final result = await _detectionService.detectTargetAsync(imagePath, targetType);
final result = await _detectionService.detectTargetAsync(
imagePath,
targetType,
);
if (!result.success) {
_state = AnalysisState.error;
@@ -512,11 +514,14 @@ class AnalysisProvider extends ChangeNotifier {
double radius, {
int? ringCount,
List<double>? ringRadii,
double zoomScale = 1.0,
Offset offset = Offset.zero,
}) {
_targetCenterX = centerX;
_targetCenterY = centerY;
_targetInnerRadius = innerRadius;
_targetRadius = radius;
_targetCenterX = (centerX - offset.dx) / zoomScale;
_targetCenterY = (centerY - offset.dy) / zoomScale;
_targetRadius = radius / zoomScale;
_targetInnerRadius = innerRadius / zoomScale;
if (ringCount != null) {
_ringCount = ringCount;
}
@@ -720,14 +725,14 @@ class AnalysisProvider extends ChangeNotifier {
return success;
}
/// Save the session (Refactorisé pour appliquer le paramètre de date personnalisé)
/// Save the session
Future<TargetAnalysis> saveSession({
String? notes,
String? sessionId,
String? weaponName,
String? weaponId,
int? distance,
DateTime? date, // <-- REÇOIT PROPREMENT LA DATE DE DEBUT DE SESSION
DateTime? date,
}) async {
if (_imagePath == null || _targetType == null) {
throw Exception('Cannot save: missing image or target type');
@@ -751,7 +756,10 @@ class AnalysisProvider extends ChangeNotifier {
if (sessionId != null && sessionId != 'standalone') {
final existingSession = await _sessionRepository.getSession(sessionId);
if (existingSession != null) {
await _sessionRepository.addAnalysisToSession(sessionId, targetAnalysis);
await _sessionRepository.addAnalysisToSession(
sessionId,
targetAnalysis,
);
} else {
// CORRECTION : Utilise la date injectée plutôt que DateTime.now()
await _sessionRepository.createSession(
@@ -788,7 +796,7 @@ class AnalysisProvider extends ChangeNotifier {
_errorMessage = null;
_imagePath = null;
_targetType = null;
_cropRotation = 0.0; // Reset de la rotation
_cropRotation = 0.0;
_targetCenterX = 0.5;
_targetCenterY = 0.5;
_targetRadius = 0.4;

View File

@@ -49,7 +49,8 @@ class AnalysisScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 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!)
: null;
@@ -65,7 +66,12 @@ class AnalysisScreen extends StatelessWidget {
if (cropRotation != null) {
p.setCropRotation(cropRotation!);
}
p.analyzeImage(imagePath, targetType, autoAnalyze: false, manualCenter: manualCenterOffset);
p.analyzeImage(
imagePath,
targetType,
autoAnalyze: false,
manualCenter: manualCenterOffset,
);
return p;
},
child: _AnalysisScreenContent(
@@ -93,7 +99,8 @@ class _AnalysisScreenContent extends StatefulWidget {
}
class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
final GlobalKey<TargetCalibrationState> _calibrationKey = GlobalKey<TargetCalibrationState>();
final GlobalKey<TargetCalibrationState> _calibrationKey =
GlobalKey<TargetCalibrationState>();
// Forcé à TRUE pour démarrer sur l'ajustement des cercles
bool _isCalibrating = true;
@@ -101,7 +108,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
bool _isAtBottom = false;
final ScrollController _scrollController = ScrollController();
final TransformationController _transformationController = TransformationController();
final TransformationController _transformationController =
TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
String? _movingShotId;
@@ -115,7 +123,9 @@ 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;
@@ -145,10 +155,14 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>();
final sessionProvider = context.watch<SessionProvider>();
final targetNumber = sessionProvider.isSessionActive ? sessionProvider.targetCount + 1 : null;
final targetNumber = sessionProvider.isSessionActive
? sessionProvider.targetCount + 1
: null;
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(
@@ -178,14 +192,20 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
if (!_isCalibrating && !_isSelectingReferences)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => provider.analyzeImage(context.read<AnalysisProvider>().imagePath!, context.read<AnalysisProvider>().targetType!),
onPressed: () => provider.analyzeImage(
context.read<AnalysisProvider>().imagePath!,
context.read<AnalysisProvider>().targetType!,
),
),
if (_isCalibrating)
TextButton(
onPressed: () => setState(() => _isCalibrating = false),
child: const Text(
'TERMINER',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
@@ -212,7 +232,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
),
// SECTION CORRIGÉE : Utilise maintenant la réactivité du provider globale
AspectRatio(
aspectRatio: provider.imageAspectRatio,
child: _isCalibrating
@@ -221,19 +240,26 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
children: [
ClipRect(
child: Builder(
builder: (context) {
return Transform(
transform: Matrix4.identity()
..setTranslationRaw(widget.cropOffset?.dx ?? 0.0, widget.cropOffset?.dy ?? 0.0, 0.0)
..scale(1.0, 1.0)
..rotateZ((provider.cropRotation) * (math.pi / 180)),
alignment: Alignment.center,
child: Image.file(
File(provider.imagePath!),
fit: BoxFit.contain,
builder: (context) {
return Transform(
transform: Matrix4.identity()
..setTranslationRaw(
widget.cropOffset?.dx ?? 0.0,
widget.cropOffset?.dy ?? 0.0,
0.0,
)
..scale(1.0, 1.0)
..rotateZ(
(provider.cropRotation) *
(math.pi / 180),
),
);
}
alignment: Alignment.center,
child: Image.file(
File(provider.imagePath!),
fit: BoxFit.contain,
),
);
},
),
),
TargetCalibration(
@@ -245,7 +271,15 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
initialRingCount: provider.ringCount,
initialRingRadii: provider.ringRadii,
targetType: provider.targetType!,
onCalibrationChanged: (centerX, centerY, innerRadius, radius, ringCount, {ringRadii}) {
onCalibrationChanged:
(
centerX,
centerY,
innerRadius,
radius,
ringCount, {
ringRadii,
}) {
provider.adjustTargetPosition(
centerX,
centerY,
@@ -253,6 +287,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
radius,
ringCount: ringCount,
ringRadii: ringRadii,
zoomScale: widget.cropScale ?? 1.0,
);
},
),
@@ -269,10 +304,18 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
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),
),
),
@@ -284,7 +327,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
targetType: provider.targetType!,
),
const SizedBox(height: 12),
if (provider.groupingResult != null && provider.shotCount > 1)
if (provider.groupingResult != null &&
provider.shotCount > 1)
GroupingStats(
groupingResult: provider.groupingResult!,
targetCenterX: provider.targetCenterX,
@@ -303,16 +347,24 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
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(
child: Builder(
builder: (context) {
final size = MediaQuery.of(context).size;
return _calibrationKey.currentState?.buildDirectionalControls(context, size)
?? const SizedBox.shrink();
}
builder: (context) {
final size = MediaQuery.of(context).size;
return _calibrationKey.currentState
?.buildDirectionalControls(
context,
size,
) ??
const SizedBox.shrink();
},
),
),
const SizedBox(height: 16),
@@ -324,19 +376,33 @@ 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: [
const Text('Centre: '),
Text(
'(${(provider.targetCenterX * 100).toStringAsFixed(1)}%, ${(provider.targetCenterY * 100).toStringAsFixed(1)}%)',
style: const TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
@@ -345,7 +411,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
const Text('Rayon: '),
Text(
'${(provider.targetRadius * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
@@ -364,13 +432,18 @@ 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(
onPressed: () => _showSaveSessionDialog(context, provider),
onPressed: () =>
_showSaveSessionDialog(context, provider),
backgroundColor: AppTheme.primaryColor,
icon: const Icon(Icons.save),
label: const Text('TERMINER LA SESSION'),
@@ -396,7 +469,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
);
}
Widget _buildZoomableImageWithOverlay(BuildContext context, AnalysisProvider provider) {
Widget _buildZoomableImageWithOverlay(
BuildContext context,
AnalysisProvider provider,
) {
return InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
@@ -404,15 +480,19 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
boundaryMargin: const EdgeInsets.all(double.infinity),
panEnabled: _movingShotId == null,
child: Transform(
// CORRECTION GÉOMÉTRIQUE : On déplace le Transform ICI pour englober le GestureDetector et le Stack d'un coup !
transform: Matrix4.identity()
..setTranslationRaw(widget.cropOffset?.dx ?? 0.0, widget.cropOffset?.dy ?? 0.0, 0.0)
..setTranslationRaw(
widget.cropOffset?.dx ?? 0.0,
widget.cropOffset?.dy ?? 0.0,
0.0,
)
..scale(1.0, 1.0)
..rotateZ((provider.cropRotation) * (math.pi / 180)),
alignment: Alignment.center,
child: GestureDetector(
onDoubleTapDown: (TapDownDetails details) {
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final localOffset = box.globalToLocal(details.globalPosition);
@@ -441,7 +521,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
}
},
onLongPressStart: (LongPressStartDetails details) {
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final localOffset = box.globalToLocal(details.globalPosition);
@@ -474,10 +555,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) {
if (_movingShotId == null) return;
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final adjustedGlobalPosition = details.globalPosition + const Offset(-25, -35);
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);
@@ -507,7 +590,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
onShotTapped: (shot) => _showShotDetails(context, provider, shot),
onShotTapped: (shot) =>
_showShotDetails(context, provider, shot),
onAddShot: (relX, relY) => provider.addShot(relX, relY),
),
],
@@ -516,11 +600,16 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
);
}
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
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(
@@ -534,7 +623,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
),
Text(
'ID: ${shot.id}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
),
const SizedBox(height: 16),
ListTile(
@@ -543,10 +634,18 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
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)),
))
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(
'$s',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
)
.toList(),
onChanged: (newScore) {
if (newScore != null) {
@@ -566,7 +665,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Navigator.pop(context);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text('SUPPRIMER', style: TextStyle(color: Colors.red)),
label: const Text(
'SUPPRIMER',
style: TextStyle(color: Colors.red),
),
),
),
],
@@ -631,14 +733,19 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Navigator.pop(context);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const CaptureScreen()),
MaterialPageRoute(
builder: (context) => const CaptureScreen(),
),
(route) => route.isFirst,
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
@@ -665,7 +772,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}