From a4dc26fda881f615ca6b6673b3359e2fd6c00e92 Mon Sep 17 00:00:00 2001 From: qlionbleusam Date: Tue, 4 Aug 2026 15:57:10 +0200 Subject: [PATCH] refactor: reglages de calibration au-dessus de la photo + boutons de taille fonctionnels --- lib/features/analysis/analysis_screen.dart | 141 +++++++- .../analysis/widgets/target_calibration.dart | 316 ++++++++---------- lib/features/home/home_screen.dart | 2 +- 3 files changed, 258 insertions(+), 201 deletions(-) diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 51bbd66e..13ced5b2 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -110,6 +110,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { bool _isCalibrating = true; bool _isAtBottom = false; + // Affichage du réglage manuel de l'espacement des anneaux. + bool _showSpacing = false; + final ScrollController _scrollController = ScrollController(); final GlobalKey _imageKey = GlobalKey(); @@ -143,7 +146,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { /// La cible du mode Plotting est désormais un élément fixe (aucun zoom à /// réinitialiser) : on se contente donc de rebasculer l'état. void _enterCalibration() { - setState(() => _isCalibrating = true); + setState(() { + _isCalibrating = true; + // La calibration est reconstruite à neuf (espacement manuel désactivé) : + // on aligne l'état du panneau pour ne pas afficher un mode inactif. + _showSpacing = false; + }); } /// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant. @@ -182,6 +190,113 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { return widget.originalImagePath ?? provider.imagePath!; } + /// Panneau de réglages de la calibration (taille + espacement). + /// + /// Rendu AU-DESSUS de l'image (et non plus en surimpression) pour ne pas + /// masquer la cible. Les valeurs affichées viennent du provider ; les + /// modifications sont poussées dans l'état de [TargetCalibration] via sa clé. + Widget _buildCalibrationSettings(AnalysisProvider provider) { + final radius = provider.targetRadius.clamp( + TargetCalibrationState.minRadius, + TargetCalibrationState.maxRadius, + ); + final spacing = + (provider.targetRadius > 0 + ? provider.targetInnerRadius / provider.targetRadius + : 0.1) + .clamp( + TargetCalibrationState.minSpacing, + TargetCalibrationState.maxSpacing, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 36, + child: Row( + children: [ + const Text( + 'Taille', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 4), + const Icon(Icons.zoom_out, size: 16), + Expanded( + child: Slider( + value: radius, + min: TargetCalibrationState.minRadius, + max: TargetCalibrationState.maxRadius, + activeColor: AppTheme.primaryColor, + onChanged: (value) => + _calibrationKey.currentState?.setRadius(value), + ), + ), + const Icon(Icons.zoom_in, size: 16), + ], + ), + ), + SizedBox( + height: 32, + child: Row( + children: [ + const Expanded( + child: Text( + 'Options d\'espacement avancées', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500), + ), + ), + Switch( + value: _showSpacing, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) { + setState(() => _showSpacing = value); + _calibrationKey.currentState?.setSpacingMode(value); + }, + ), + ], + ), + ), + if (_showSpacing) + SizedBox( + height: 36, + child: Row( + children: [ + const Text( + 'Espacement', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 4), + const Icon(Icons.compress, size: 16), + Expanded( + child: Slider( + value: spacing, + min: TargetCalibrationState.minSpacing, + max: TargetCalibrationState.maxSpacing, + activeColor: Colors.orange, + onChanged: (value) => + _calibrationKey.currentState?.setSpacingRatio(value), + ), + ), + const Icon(Icons.expand, size: 16), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: 'Réinitialiser l\'espacement', + constraints: const BoxConstraints(), + padding: const EdgeInsets.symmetric(horizontal: 8), + onPressed: () => + _calibrationKey.currentState?.resetSpacing(), + ), + ], + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final provider = context.watch(); @@ -230,21 +345,17 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { controller: _scrollController, child: Column( children: [ - Padding( - padding: const EdgeInsets.all(AppConstants.defaultPadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (provider.state == AnalysisState.loading) - const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: CircularProgressIndicator(), - ), - ), - ], + // Plus de bloc vide au-dessus de l'image : l'indicateur n'occupe + // de la place que pendant le chargement. + if (provider.state == AnalysisState.loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Center(child: CircularProgressIndicator()), ), - ), + + // Réglages de calibration : au-dessus de la photo pour ne rien + // masquer de la cible. + if (_isCalibrating) _buildCalibrationSettings(provider), AspectRatio( aspectRatio: provider.imageAspectRatio, diff --git a/lib/features/analysis/widgets/target_calibration.dart b/lib/features/analysis/widgets/target_calibration.dart index 556a7b44..2449f612 100644 --- a/lib/features/analysis/widgets/target_calibration.dart +++ b/lib/features/analysis/widgets/target_calibration.dart @@ -6,7 +6,6 @@ library; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../data/models/target_type.dart'; class TargetCalibration extends StatefulWidget { @@ -44,6 +43,15 @@ class TargetCalibration extends StatefulWidget { } class TargetCalibrationState extends State { + /// Bornes du rayon global (mesurées pour laisser de la liberté sans + /// débordement incontrôlé). + static const double minRadius = 0.3; + static const double maxRadius = 0.95; + + /// Bornes de l'espacement : max bridé à 0.70 pour confiner le dernier cercle. + static const double minSpacing = 0.01; + static const double maxSpacing = 0.70; + late double _centerX; late double _centerY; late double _radius; @@ -154,189 +162,116 @@ class TargetCalibrationState extends State { builder: (context, constraints) { final size = constraints.biggest; - return Stack( - children: [ - GestureDetector( - onScaleStart: (details) { - _baseRadiusBeforeScale = _radius; - final tapX = details.localFocalPoint.dx / size.width; - final tapY = details.localFocalPoint.dy / size.height; - final distToCenter = _distance(tapX, tapY, _centerX, _centerY); + // Les réglages (taille / espacement) sont rendus par l'écran hôte + // AU-DESSUS de l'image : rien ne vient masquer la cible ici. + return GestureDetector( + onScaleStart: (details) { + _baseRadiusBeforeScale = _radius; + final tapX = details.localFocalPoint.dx / size.width; + final tapY = details.localFocalPoint.dy / size.height; + final distToCenter = _distance(tapX, tapY, _centerX, _centerY); - if (distToCenter < 0.05 || distToCenter < _radius + 0.02) { - setState(() { - _isDraggingCenter = true; - }); - } - }, - onScaleUpdate: (details) => _onScaleUpdate(details, size), - onScaleEnd: (_) => _onScaleEnd(), - child: CustomPaint( - size: size, - painter: _CalibrationPainter( - centerX: _centerX, - centerY: _centerY, - radius: _radius, - innerRadius: _innerRadius, - ringCount: _ringCount, - ringRadii: _ringRadii, - targetType: widget.targetType, - isDraggingCenter: _isDraggingCenter, - isDraggingRadius: false, - isDraggingInnerRadius: false, - ), - ), + if (distToCenter < 0.05 || distToCenter < _radius + 0.02) { + setState(() { + _isDraggingCenter = true; + }); + } + }, + onScaleUpdate: (details) => _onScaleUpdate(details, size), + onScaleEnd: (_) => _onScaleEnd(), + child: CustomPaint( + size: size, + painter: _CalibrationPainter( + centerX: _centerX, + centerY: _centerY, + radius: _radius, + innerRadius: _innerRadius, + ringCount: _ringCount, + ringRadii: _ringRadii, + targetType: widget.targetType, + isDraggingCenter: _isDraggingCenter, + isDraggingRadius: false, + isDraggingInnerRadius: false, ), - - Positioned( - top: 10, - left: 40, - right: 40, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Options d\'espacement avancées', - style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500), - ), - SizedBox( - height: 28, - child: Switch( - value: _showEspacement, - activeThumbColor: const Color(0xFF00FF00), - onChanged: (bool value) { - setState(() { - _showEspacement = value; - // Quand on désactive l'espacement manuel, on restaure la configuration d'usine ! - if (!value) { - _initRingRadii(forceRecalculate: false); - } - }); - _notifyChange(); - }, - ), - ), - ], - ), - ), - - // Slider pour la taille (toujours visible) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - children: [ - const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)), - const Icon(Icons.zoom_out, color: Colors.white, size: 16), - Expanded( - child: Slider( - // SÉCURITÉ : Ouverture mesurée des bornes pour plus de liberté sans débordement incontrôlé - value: _radius.clamp(0.3, 0.95), - min: 0.3, - max: 0.95, - activeColor: AppTheme.primaryColor, - onChanged: (value) { - setState(() { - _radius = value; - _innerRadius = _radius * _currentEspacementRatio; - // CORRECTION : Si le mode avancé n'est pas coché, on applique la taille pure sans détruire le ratio d'origine - _initRingRadii(forceRecalculate: _showEspacement); - }); - _notifyChange(); - }, - ), - ), - const Icon(Icons.zoom_in, color: Colors.white, size: 16), - ], - ), - ), - - // Affichage conditionnel du slider d'espacement orange - if (_showEspacement) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.fromLTRB(16, 4, 4, 4), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - children: [ - const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)), - const Icon(Icons.compress, color: Colors.white, size: 16), - Expanded( - child: Row( - children: [ - Expanded( - child: Slider( - value: _currentEspacementRatio, - min: 0.01, - // SÉCURITÉ : Écartement max bridé à 0.70 pour confiner le dernier cercle - max: 0.70, - activeColor: Colors.orange, - onChanged: (value) { - setState(() { - _currentEspacementRatio = value; - _innerRadius = _radius * _currentEspacementRatio; - _initRingRadii(forceRecalculate: true); - }); - _notifyChange(); - }, - ), - ), - const Icon(Icons.expand, color: Colors.white, size: 16), - ], - ), - ), - // CORRECTION DU BOUTON RESET : Restaure désormais le vrai profil d'usine de l'IA - IconButton( - icon: const Icon(Icons.refresh, color: Colors.white70, size: 20), - onPressed: () { - setState(() { - if (_originalRingRadii != null) { - _initRingRadii(forceRecalculate: false); - if (_ringRadii.isNotEmpty) { - _currentEspacementRatio = _ringRadii.first; - _innerRadius = _radius * _currentEspacementRatio; - } - } else { - _currentEspacementRatio = 0.1; - _innerRadius = _radius * _currentEspacementRatio; - _initRingRadii(forceRecalculate: true); - } - }); - _notifyChange(); - }, - tooltip: 'Réinitialiser l\'espacement', - constraints: const BoxConstraints(), - padding: const EdgeInsets.symmetric(horizontal: 8), - ), - ], - ), - ), - ], - ], - ), - ), - ], + ), ); }, ); } + // --------------------------------------------------------------------------- + // API publique pilotée par l'écran hôte (panneau de réglages hors de l'image) + // --------------------------------------------------------------------------- + + /// Espacement courant (rayon du premier anneau, en fraction du rayon global). + double get spacingRatio => _currentEspacementRatio; + + /// Mode d'espacement manuel actif ou non. + bool get isSpacingModeEnabled => _showEspacement; + + /// Applique une nouvelle taille globale (rayon normalisé). + void setRadius(double value) { + setState(() { + _radius = value.clamp(minRadius, maxRadius); + _innerRadius = _radius * _currentEspacementRatio; + // Si le mode avancé n'est pas coché, on applique la taille pure sans + // détruire le ratio d'origine. + _initRingRadii(forceRecalculate: _showEspacement); + }); + _notifyChange(); + } + + /// Agrandit / réduit la cible de [deltaPixels] pixels. + /// + /// La conversion utilise la plus petite dimension de [size], exactement comme + /// le painter, pour que le pas corresponde bien à un pixel à l'écran. + void adjustRadiusByPixels(double deltaPixels, Size size) { + final minDim = size.width < size.height ? size.width : size.height; + if (minDim <= 0) return; + setRadius(_radius + deltaPixels / minDim); + } + + /// Active ou non le réglage manuel de l'espacement. + /// + /// À la désactivation, on restaure la configuration d'usine. + void setSpacingMode(bool enabled) { + setState(() { + _showEspacement = enabled; + if (!enabled) { + _initRingRadii(forceRecalculate: false); + } + }); + _notifyChange(); + } + + /// Applique un nouvel espacement entre les anneaux. + void setSpacingRatio(double value) { + setState(() { + _currentEspacementRatio = value.clamp(minSpacing, maxSpacing); + _innerRadius = _radius * _currentEspacementRatio; + _initRingRadii(forceRecalculate: true); + }); + _notifyChange(); + } + + /// Restaure le vrai profil d'usine détecté sur l'image. + void resetSpacing() { + setState(() { + if (_originalRingRadii != null) { + _initRingRadii(forceRecalculate: false); + if (_ringRadii.isNotEmpty) { + _currentEspacementRatio = _ringRadii.first; + _innerRadius = _radius * _currentEspacementRatio; + } + } else { + _currentEspacementRatio = 0.1; + _innerRadius = _radius * _currentEspacementRatio; + _initRingRadii(forceRecalculate: true); + } + }); + _notifyChange(); + } + Widget buildDirectionalControls(BuildContext context, Size size) { return Container( padding: const EdgeInsets.all(8), @@ -348,7 +283,8 @@ class TargetCalibrationState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildSignLabel('−'), + // Réduit la cible d'un pixel. + _buildSizeButton('−', () => adjustRadiusByPixels(-1, size)), const SizedBox(width: 12), Column( mainAxisSize: MainAxisSize.min, @@ -366,17 +302,27 @@ class TargetCalibrationState extends State { ], ), const SizedBox(width: 12), - _buildSignLabel('+'), + // Agrandit la cible d'un pixel. + _buildSizeButton('+', () => adjustRadiusByPixels(1, size)), ], ), ); } - // Symbole purement décoratif affiché de part et d'autre de la croix. - Widget _buildSignLabel(String text) { - return Text( - text, - style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold), + /// Bouton − / + de part et d'autre de la croix : ajuste la TAILLE de la cible. + Widget _buildSizeButton(String sign, VoidCallback onPressed) { + return Container( + decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)), + child: IconButton( + icon: Text( + sign, + style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold), + ), + onPressed: onPressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 44, minHeight: 44), + tooltip: sign == '+' ? 'Agrandir la cible' : 'Réduire la cible', + ), ); } @@ -415,7 +361,7 @@ class TargetCalibrationState extends State { void _onScaleUpdate(ScaleUpdateDetails details, Size size) { setState(() { if (details.pointerCount == 2) { - _radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95); + _radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius); _innerRadius = _radius * _currentEspacementRatio; _initRingRadii(forceRecalculate: _showEspacement); } else if (_isDraggingCenter) { diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index ff69de35..31fb35c4 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -342,7 +342,7 @@ class _HomeScreenState extends State { Expanded( child: StatsCard( icon: Icons.emoji_events, - title: 'Meilleur', + title: 'Meilleur score', value: '${_stats!['bestScore']}', color: AppTheme.successColor, ),