refactor: reglages de calibration au-dessus de la photo + boutons de taille fonctionnels

This commit is contained in:
qlionbleusam
2026-08-04 15:57:10 +02:00
parent 6130e63cfb
commit a4dc26fda8
3 changed files with 258 additions and 201 deletions
+124 -13
View File
@@ -110,6 +110,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
bool _isCalibrating = true; bool _isCalibrating = true;
bool _isAtBottom = false; bool _isAtBottom = false;
// Affichage du réglage manuel de l'espacement des anneaux.
bool _showSpacing = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final GlobalKey _imageKey = GlobalKey(); 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 à /// La cible du mode Plotting est désormais un élément fixe (aucun zoom à
/// réinitialiser) : on se contente donc de rebasculer l'état. /// réinitialiser) : on se contente donc de rebasculer l'état.
void _enterCalibration() { 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. /// 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!; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>(); final provider = context.watch<AnalysisProvider>();
@@ -230,22 +345,18 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
controller: _scrollController, controller: _scrollController,
child: Column( child: Column(
children: [ children: [
Padding( // Plus de bloc vide au-dessus de l'image : l'indicateur n'occupe
padding: const EdgeInsets.all(AppConstants.defaultPadding), // de la place que pendant le chargement.
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (provider.state == AnalysisState.loading) if (provider.state == AnalysisState.loading)
const Center( const Padding(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8.0), padding: EdgeInsets.symmetric(vertical: 8.0),
child: CircularProgressIndicator(), 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(
aspectRatio: provider.imageAspectRatio, aspectRatio: provider.imageAspectRatio,
child: _isCalibrating child: _isCalibrating
@@ -6,7 +6,6 @@ library;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart'; import '../../../data/models/target_type.dart';
class TargetCalibration extends StatefulWidget { class TargetCalibration extends StatefulWidget {
@@ -44,6 +43,15 @@ class TargetCalibration extends StatefulWidget {
} }
class TargetCalibrationState extends State<TargetCalibration> { class TargetCalibrationState extends State<TargetCalibration> {
/// 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 _centerX;
late double _centerY; late double _centerY;
late double _radius; late double _radius;
@@ -154,9 +162,9 @@ class TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) { builder: (context, constraints) {
final size = constraints.biggest; final size = constraints.biggest;
return Stack( // Les réglages (taille / espacement) sont rendus par l'écran hôte
children: [ // AU-DESSUS de l'image : rien ne vient masquer la cible ici.
GestureDetector( return GestureDetector(
onScaleStart: (details) { onScaleStart: (details) {
_baseRadiusBeforeScale = _radius; _baseRadiusBeforeScale = _radius;
final tapX = details.localFocalPoint.dx / size.width; final tapX = details.localFocalPoint.dx / size.width;
@@ -186,125 +194,68 @@ class TargetCalibrationState extends State<TargetCalibration> {
isDraggingInnerRadius: false, isDraggingInnerRadius: false,
), ),
), ),
), );
},
);
}
Positioned( // ---------------------------------------------------------------------------
top: 10, // API publique pilotée par l'écran hôte (panneau de réglages hors de l'image)
left: 40, // ---------------------------------------------------------------------------
right: 40,
child: Column( /// Espacement courant (rayon du premier anneau, en fraction du rayon global).
mainAxisSize: MainAxisSize.min, double get spacingRatio => _currentEspacementRatio;
children: [
Container( /// Mode d'espacement manuel actif ou non.
margin: const EdgeInsets.only(bottom: 8), bool get isSpacingModeEnabled => _showEspacement;
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
decoration: BoxDecoration( /// Applique une nouvelle taille globale (rayon normalisé).
color: Colors.black54, void setRadius(double value) {
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(() { setState(() {
_showEspacement = value; _radius = value.clamp(minRadius, maxRadius);
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine ! _innerRadius = _radius * _currentEspacementRatio;
if (!value) { // 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); _initRingRadii(forceRecalculate: false);
} }
}); });
_notifyChange(); _notifyChange();
}, }
),
),
],
),
),
// Slider pour la taille (toujours visible) /// Applique un nouvel espacement entre les anneaux.
Container( void setSpacingRatio(double value) {
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(() { setState(() {
_radius = value; _currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
_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; _innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: true); _initRingRadii(forceRecalculate: true);
}); });
_notifyChange(); _notifyChange();
}, }
),
), /// Restaure le vrai profil d'usine détecté sur l'image.
const Icon(Icons.expand, color: Colors.white, size: 16), void resetSpacing() {
],
),
),
// 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(() { setState(() {
if (_originalRingRadii != null) { if (_originalRingRadii != null) {
_initRingRadii(forceRecalculate: false); _initRingRadii(forceRecalculate: false);
@@ -319,22 +270,6 @@ class TargetCalibrationState extends State<TargetCalibration> {
} }
}); });
_notifyChange(); _notifyChange();
},
tooltip: 'Réinitialiser l\'espacement',
constraints: const BoxConstraints(),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
],
),
),
],
],
),
),
],
);
},
);
} }
Widget buildDirectionalControls(BuildContext context, Size size) { Widget buildDirectionalControls(BuildContext context, Size size) {
@@ -348,7 +283,8 @@ class TargetCalibrationState extends State<TargetCalibration> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildSignLabel(''), // Réduit la cible d'un pixel.
_buildSizeButton('', () => adjustRadiusByPixels(-1, size)),
const SizedBox(width: 12), const SizedBox(width: 12),
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -366,17 +302,27 @@ class TargetCalibrationState extends State<TargetCalibration> {
], ],
), ),
const SizedBox(width: 12), 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. /// Bouton / + de part et d'autre de la croix : ajuste la TAILLE de la cible.
Widget _buildSignLabel(String text) { Widget _buildSizeButton(String sign, VoidCallback onPressed) {
return Text( return Container(
text, decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold), 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<TargetCalibration> {
void _onScaleUpdate(ScaleUpdateDetails details, Size size) { void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
setState(() { setState(() {
if (details.pointerCount == 2) { if (details.pointerCount == 2) {
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95); _radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius);
_innerRadius = _radius * _currentEspacementRatio; _innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: _showEspacement); _initRingRadii(forceRecalculate: _showEspacement);
} else if (_isDraggingCenter) { } else if (_isDraggingCenter) {
+1 -1
View File
@@ -342,7 +342,7 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded( Expanded(
child: StatsCard( child: StatsCard(
icon: Icons.emoji_events, icon: Icons.emoji_events,
title: 'Meilleur', title: 'Meilleur score',
value: '${_stats!['bestScore']}', value: '${_stats!['bestScore']}',
color: AppTheme.successColor, color: AppTheme.successColor,
), ),