Compare commits

..
3 Commits
Author SHA1 Message Date
qlionbleusamandClaude Opus 4.8 b641e80d62 feat: boutons valider flottants + reset impacts dans l'éditeur, export IA dans la popup fin de session
- VALIDER en bouton bleu flottant sur calibration et placement des impacts
- corbeille (reset impacts) à côté du VALIDER dans l'éditeur
- retrait des boutons refresh/cloud de l'AppBar du plotting
- export IA déplacé dans la popup de fin de session

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:22:37 +02:00
qlionbleusamandClaude Opus 4.8 df5f84c87c refactor: flux calibration -> plotting -> placement des impacts
- VALIDER en calibration mène directement au plotting
- l'éditeur d'impacts revient toujours au plotting au retour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:39:19 +02:00
qlionbleusam bbf10d7eed refactor: cible du plotting cliquable et fixe, retrait bouton modifier impacts 2026-07-22 16:15:25 +02:00
2 changed files with 108 additions and 157 deletions
+87 -150
View File
@@ -25,7 +25,6 @@ import 'widgets/target_overlay.dart';
import 'widgets/target_calibration.dart';
import 'widgets/score_card.dart';
import 'widgets/grouping_stats.dart';
import 'widgets/shot_details_sheet.dart';
class AnalysisScreen extends StatelessWidget {
final String imagePath;
@@ -111,15 +110,11 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
bool _isAtBottom = false;
final ScrollController _scrollController = ScrollController();
final TransformationController _transformationController =
TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
@override
void initState() {
super.initState();
_transformationController.addListener(_onTransformChanged);
_scrollController.addListener(_onScroll);
}
@@ -137,31 +132,16 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
@override
void dispose() {
_transformationController.removeListener(_onTransformChanged);
_transformationController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onTransformChanged() {
final scale = _transformationController.value.getMaxScaleOnAxis();
if (scale != _currentZoomScale) {
setState(() {
_currentZoomScale = scale;
});
}
}
/// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer.
/// Repasse en mode calibration.
///
/// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting
/// persiste dans le TransformationController et se réapplique au retour,
/// ce qui faisait "zoomer" légèrement la photo. On repart donc toujours
/// d'une transformation identité (zoom 1.0).
/// 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() {
_transformationController.value = Matrix4.identity();
_currentZoomScale = 1.0;
setState(() => _isCalibrating = true);
}
@@ -171,10 +151,11 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
/// exactement le même AnalysisProvider que cet écran : les impacts ajoutés,
/// déplacés ou supprimés sont donc immédiatement répercutés ici.
///
/// Au retour : si l'utilisateur a validé (résultat true) on bascule en mode
/// Plotting (lecture seule) ; sinon on repasse en calibration.
/// Au retour, quel que soit le résultat (validation OU retour arrière), on
/// revient TOUJOURS sur le Plotting. L'éditeur d'impacts n'est ouvert que
/// depuis le Plotting : il doit donc y ramener, jamais sur la calibration.
Future<void> _openImpactEditor(AnalysisProvider provider) async {
final validated = await Navigator.of(context).push<bool>(
await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
value: provider,
@@ -185,13 +166,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
if (!mounted) return;
if (validated == true) {
setState(() {
_isCalibrating = false;
});
} else {
_enterCalibration();
}
setState(() {
_isCalibrating = false;
});
}
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
@@ -244,87 +221,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
}
},
),
actions: [
// Remise à zéro des impacts : efface tous les impacts en un clic,
// sans modifier la calibration (centre, rayon, anneaux).
if (!_isCalibrating)
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Effacer tous les impacts',
onPressed: () => provider.clearShots(),
),
// Nuage d'export vers le backend IA : visible uniquement si l'analyse
// a réussi ET que l'utilisateur a activé l'option dans les Paramètres.
if (!_isCalibrating)
FutureBuilder<bool>(
future: WalletIdentityService().isUploadEnabled(),
builder: (context, snapshot) {
final isEnabled = snapshot.data ?? false;
if (!isEnabled ||
provider.state != AnalysisState.success) {
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.cloud_upload),
tooltip: 'Exporter pour IA',
onPressed: () async {
final p = context.read<AnalysisProvider>();
if (p.state != AnalysisState.success) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Exportation en cours...')),
);
// Métadonnées réelles de la session en cours (distance,
// arme) plutôt que les placeholders par défaut.
final sp = context.read<SessionProvider>();
final success = await p.exportToAiBackend(
sessionId: sp.activeSessionId,
distance: sp.distance,
weapon: sp.currentWeapon,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Export réussi vers le backend IA !'),
backgroundColor: AppTheme.successColor,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(p.errorMessage ?? 'Erreur d\'export'),
backgroundColor: AppTheme.errorColor,
),
);
}
},
);
},
),
if (_isCalibrating)
TextButton(
onPressed: () {
// On fige la calibration courante AVANT d'ouvrir l'éditeur,
// puis on passe sur l'écran d'édition d'impacts plein écran
// (zoom fiable + placement). Le mode Plotting (lecture seule)
// s'affichera au retour si l'utilisateur valide.
_calibrationKey.currentState?.commitCalibration();
_openImpactEditor(context.read<AnalysisProvider>());
},
child: const Text(
'VALIDER',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
actions: const [],
),
body: Stack(
children: [
@@ -416,27 +313,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
Card(
color: AppTheme.primaryColor.withValues(alpha: 0.18),
child: ListTile(
leading: const Icon(
Icons.edit_location_alt,
color: AppTheme.primaryColor,
),
title: const Text('Modifier les impacts'),
subtitle: const Text(
'Ajouter, déplacer ou supprimer des impacts (plein écran)',
),
trailing: const Icon(
Icons.open_in_full,
size: 16,
),
// Rouvre l'éditeur plein écran en partageant le provider.
onTap: () =>
_openImpactEditor(context.read<AnalysisProvider>()),
),
),
const SizedBox(height: 12),
Card(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: ListTile(
@@ -575,7 +451,17 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
? EdgeInsets.zero
: const EdgeInsets.all(16.0),
child: _isCalibrating
? const SizedBox.shrink()
? FloatingActionButton.extended(
// Même bouton bleu flottant que « TERMINER LA SESSION » :
// on fige la calibration puis on bascule sur le Plotting.
onPressed: () {
_calibrationKey.currentState?.commitCalibration();
setState(() => _isCalibrating = false);
},
backgroundColor: AppTheme.primaryColor,
icon: const Icon(Icons.check),
label: const Text('VALIDER'),
)
: FloatingActionButton.extended(
onPressed: () =>
_showSaveSessionDialog(context, provider),
@@ -606,20 +492,19 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
/// Affichage du plotting en LECTURE SEULE.
///
/// L'édition (ajout / déplacement / suppression) se fait désormais
/// exclusivement dans l'éditeur plein écran (ImpactEditorScreen). Ici on se
/// contente d'afficher l'image + l'overlay, avec un zoom de consultation.
/// Le tap sur un impact ouvre simplement ses détails.
/// La cible est un élément d'écran FIXE : elle ne se déplace pas et ne se
/// zoome pas (plus d'InteractiveViewer). Un tap n'importe où sur la cible
/// ouvre directement l'éditeur d'impacts plein écran (ImpactEditorScreen),
/// exactement comme le faisait l'ancien bouton « Modifier les impacts ».
/// C'est là que se fait toute l'édition (ajout / déplacement / suppression),
/// avec un zoom fiable.
Widget _buildReadOnlyPlotImage(
BuildContext context,
AnalysisProvider provider,
) {
return InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(80),
panEnabled: true,
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openImpactEditor(context.read<AnalysisProvider>()),
child: Stack(
children: [
Image.file(
@@ -635,10 +520,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
targetType: provider.targetType!,
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
// Lecture seule : tap sur impact -> détails (consultation).
onShotTapped: (shot) =>
showShotDetailsSheet(context, provider, shot),
zoomScale: 1.0,
),
),
],
@@ -659,6 +541,61 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
Text('Score total: ${provider.totalScore}'),
const SizedBox(height: 16),
const Text('Voulez-vous enregistrer cette session ?'),
// Export vers le backend IA : proposé ici (et non plus dans
// l'AppBar). Visible seulement si l'option est activée dans les
// Paramètres ET si l'analyse a réussi.
FutureBuilder<bool>(
future: WalletIdentityService().isUploadEnabled(),
builder: (context, snapshot) {
final isEnabled = snapshot.data ?? false;
if (!isEnabled ||
provider.state != AnalysisState.success) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 12),
child: OutlinedButton.icon(
icon: const Icon(Icons.cloud_upload),
label: const Text('Exporter pour IA'),
onPressed: () async {
if (provider.state != AnalysisState.success) return;
// On capture messenger et session AVANT l'await pour
// éviter tout usage de context après un gap async.
final messenger = ScaffoldMessenger.of(context);
final sp = context.read<SessionProvider>();
messenger.showSnackBar(
const SnackBar(
content: Text('Exportation en cours...'),
),
);
final success = await provider.exportToAiBackend(
sessionId: sp.activeSessionId,
distance: sp.distance,
weapon: sp.currentWeapon,
);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: Text(
success
? 'Export réussi vers le backend IA !'
: (provider.errorMessage ??
'Erreur d\'export'),
),
backgroundColor: success
? AppTheme.successColor
: AppTheme.errorColor,
),
);
},
),
);
},
),
],
),
actions: [
@@ -20,6 +20,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/shot.dart';
import 'analysis_provider.dart';
import 'widgets/target_overlay.dart';
@@ -99,16 +100,29 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
title: Text('Placement des impacts (${provider.shotCount})'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Retour à la calibration',
tooltip: 'Retour au plotting',
onPressed: () => Navigator.pop(context, false),
),
actions: [
TextButton(
),
// Corbeille (efface tous les impacts) + bouton bleu flottant VALIDER.
// heroTag distinct sur chaque FAB pour éviter le conflit de Hero.
floatingActionButton: Row(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
heroTag: 'reset_impacts',
onPressed: () => provider.clearShots(),
backgroundColor: Colors.grey.shade800,
tooltip: 'Effacer tous les impacts',
child: const Icon(Icons.delete),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: 'validate_impacts',
onPressed: () => Navigator.pop(context, true),
child: const Text(
'VALIDER',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: AppTheme.primaryColor,
icon: const Icon(Icons.check),
label: const Text('VALIDER'),
),
],
),