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 _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<AnalysisProvider>();
@@ -230,22 +345,18 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
controller: _scrollController,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 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 Center(
child: Padding(
const Padding(
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: provider.imageAspectRatio,
child: _isCalibrating
@@ -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<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 _centerY;
late double _radius;
@@ -154,9 +162,9 @@ class TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) {
final size = constraints.biggest;
return Stack(
children: [
GestureDetector(
// 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;
@@ -186,125 +194,68 @@ class TargetCalibrationState extends State<TargetCalibration> {
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) {
// ---------------------------------------------------------------------------
// 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(() {
_showEspacement = value;
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine !
if (!value) {
_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();
},
),
),
],
),
),
}
// 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) {
/// Applique un nouvel espacement entre les anneaux.
void setSpacingRatio(double 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;
_currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
_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: () {
}
/// Restaure le vrai profil d'usine détecté sur l'image.
void resetSpacing() {
setState(() {
if (_originalRingRadii != null) {
_initRingRadii(forceRecalculate: false);
@@ -319,22 +270,6 @@ class TargetCalibrationState extends State<TargetCalibration> {
}
});
_notifyChange();
},
tooltip: 'Réinitialiser l\'espacement',
constraints: const BoxConstraints(),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
],
),
),
],
],
),
),
],
);
},
);
}
Widget buildDirectionalControls(BuildContext context, Size size) {
@@ -348,7 +283,8 @@ class TargetCalibrationState extends State<TargetCalibration> {
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<TargetCalibration> {
],
),
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<TargetCalibration> {
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) {
+1 -1
View File
@@ -342,7 +342,7 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded(
child: StatsCard(
icon: Icons.emoji_events,
title: 'Meilleur',
title: 'Meilleur score',
value: '${_stats!['bestScore']}',
color: AppTheme.successColor,
),