869 lines
32 KiB
Dart
869 lines
32 KiB
Dart
/// Écran principal de Plotting et d'analyse - Interface centrale de traitement des cibles.
|
|
///
|
|
/// Affiche d'abord la calibration de la cible, puis l'overlay des anneaux et impacts détectés.
|
|
/// Permet le calcul des scores et statistiques de groupement (Plotting).
|
|
library;
|
|
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../main_navigation_holder.dart';
|
|
import '../../core/constants/app_constants.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../data/models/target_type.dart';
|
|
import '../../data/repositories/session_repository.dart';
|
|
import '../../services/score_calculator_service.dart';
|
|
import '../../services/grouping_analyzer_service.dart';
|
|
import '../../services/wallet_identity_service.dart';
|
|
import '../session/session_provider.dart';
|
|
import 'analysis_provider.dart';
|
|
import 'impact_editor_screen.dart';
|
|
import '../crop/crop_screen.dart';
|
|
import '../capture/capture_screen.dart';
|
|
import 'widgets/target_overlay.dart';
|
|
import 'widgets/target_calibration.dart';
|
|
import 'widgets/score_card.dart';
|
|
import 'widgets/grouping_stats.dart';
|
|
|
|
class AnalysisScreen extends StatelessWidget {
|
|
final String imagePath;
|
|
final String? originalImagePath; // AJOUT : image source avant le crop à 85%
|
|
final TargetType targetType;
|
|
final double? initialCenterX;
|
|
final double? initialCenterY;
|
|
final double? cropScale;
|
|
final Offset? cropOffset;
|
|
final double? cropRotation; // Reçu proprement depuis le CropScreen
|
|
|
|
const AnalysisScreen({
|
|
super.key,
|
|
required this.imagePath,
|
|
this.originalImagePath, // AJOUT
|
|
required this.targetType,
|
|
this.initialCenterX,
|
|
this.initialCenterY,
|
|
this.cropScale,
|
|
this.cropOffset,
|
|
this.cropRotation,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Reconstitution de l'Offset pour le traitement métier en arrière-plan
|
|
final manualCenterOffset =
|
|
(initialCenterX != null && initialCenterY != null)
|
|
? Offset(initialCenterX!, initialCenterY!)
|
|
: null;
|
|
|
|
return ChangeNotifierProvider(
|
|
create: (context) {
|
|
final p = AnalysisProvider(
|
|
scoreCalculatorService: context.read<ScoreCalculatorService>(),
|
|
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
|
|
sessionRepository: context.read<SessionRepository>(),
|
|
);
|
|
// Sauvegarde de l'angle de rotation d'origine directement dans l'état global
|
|
if (cropRotation != null) {
|
|
p.setCropRotation(cropRotation!);
|
|
}
|
|
p.analyzeImage(
|
|
imagePath,
|
|
targetType,
|
|
manualCenter: manualCenterOffset,
|
|
);
|
|
return p;
|
|
},
|
|
child: _AnalysisScreenContent(
|
|
originalImagePath: originalImagePath, // AJOUT
|
|
cropScale: cropScale,
|
|
cropOffset: cropOffset,
|
|
cropRotation: cropRotation, // Envoyé à la structure d'affichage
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AnalysisScreenContent extends StatefulWidget {
|
|
final String? originalImagePath; // AJOUT
|
|
final double? cropScale;
|
|
final Offset? cropOffset;
|
|
final double? cropRotation;
|
|
|
|
const _AnalysisScreenContent({
|
|
this.originalImagePath, // AJOUT
|
|
this.cropScale,
|
|
this.cropOffset,
|
|
this.cropRotation,
|
|
});
|
|
|
|
@override
|
|
State<_AnalysisScreenContent> createState() => _AnalysisScreenContentState();
|
|
}
|
|
|
|
class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|
final GlobalKey<TargetCalibrationState> _calibrationKey =
|
|
GlobalKey<TargetCalibrationState>();
|
|
|
|
// Forcé à TRUE pour démarrer sur l'ajustement des cercles
|
|
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();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients) return;
|
|
final isBottom =
|
|
_scrollController.position.pixels >=
|
|
_scrollController.position.maxScrollExtent - 20;
|
|
if (isBottom != _isAtBottom) {
|
|
setState(() {
|
|
_isAtBottom = isBottom;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_onScroll);
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Repasse en mode calibration.
|
|
///
|
|
/// 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;
|
|
// 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.
|
|
///
|
|
/// On utilise ChangeNotifierProvider.value pour que l'éditeur lise et modifie
|
|
/// 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, 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 {
|
|
await Navigator.of(context).push<bool>(
|
|
MaterialPageRoute(
|
|
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
|
|
value: provider,
|
|
child: const ImpactEditorScreen(),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_isCalibrating = false;
|
|
});
|
|
}
|
|
|
|
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
|
|
///
|
|
/// On privilégie TOUJOURS l'image source non rognée (originalImagePath).
|
|
/// Repartir de l'image déjà croppée à 85% provoquait un rognage cumulatif
|
|
/// (0.85 x 0.85 x ...) qui re-zoomait la photo à chaque aller-retour
|
|
/// entre la capture/crop et la calibration.
|
|
String _backCropImagePath(AnalysisProvider provider) {
|
|
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(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Indication affichée sous le titre en mode Plotting.
|
|
///
|
|
/// Rien n'indiquait comment ajouter un impact une fois la calibration
|
|
/// validée : ce rappel pointe vers le geste (toucher la cible).
|
|
Widget _buildPlottingHint(AnalysisProvider provider) {
|
|
final hasShots = provider.shotCount > 0;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
hasShots ? Icons.touch_app : Icons.add_location_alt,
|
|
size: 16,
|
|
color: AppTheme.primaryColor,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
hasShots
|
|
? 'Touchez la cible pour modifier vos impacts'
|
|
: 'Touchez la cible pour placer vos impacts',
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Bouton flottant du bas de l'écran.
|
|
///
|
|
/// En calibration : VALIDER. En plotting : tant qu'aucun impact n'est placé,
|
|
/// on ne propose pas de terminer la session mais de placer un impact (le
|
|
/// bouton ouvre l'éditeur, comme un tap sur la cible).
|
|
Widget _buildBottomAction(BuildContext context, AnalysisProvider provider) {
|
|
if (_isCalibrating) {
|
|
return 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'),
|
|
);
|
|
}
|
|
|
|
if (provider.shotCount == 0) {
|
|
return FloatingActionButton.extended(
|
|
onPressed: () => _openImpactEditor(provider),
|
|
backgroundColor: AppTheme.primaryColor,
|
|
icon: const Icon(Icons.add_location_alt),
|
|
label: const Text('PLACER UN IMPACT'),
|
|
);
|
|
}
|
|
|
|
return FloatingActionButton.extended(
|
|
onPressed: () => _showSaveSessionDialog(context, provider),
|
|
backgroundColor: AppTheme.primaryColor,
|
|
icon: const Icon(Icons.save),
|
|
label: const Text('TERMINER LA SESSION'),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = context.watch<AnalysisProvider>();
|
|
final sessionProvider = context.watch<SessionProvider>();
|
|
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');
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(title),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () {
|
|
if (_isCalibrating) {
|
|
final provider = context.read<AnalysisProvider>();
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => CropScreen(
|
|
// CORRECTION : on repart de l'image SOURCE (non rognée) pour
|
|
// éviter le rognage cumulatif à 85% qui re-zoomait à chaque retour.
|
|
imagePath: _backCropImagePath(provider),
|
|
targetType: provider.targetType!,
|
|
initialScale: widget.cropScale,
|
|
initialOffset: widget.cropOffset,
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
// Retour Plotting -> Calibration : on réinitialise le zoom.
|
|
_enterCalibration();
|
|
}
|
|
},
|
|
),
|
|
actions: const [],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
SingleChildScrollView(
|
|
controller: _scrollController,
|
|
child: Column(
|
|
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 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)
|
|
else
|
|
_buildPlottingHint(provider),
|
|
|
|
AspectRatio(
|
|
aspectRatio: provider.imageAspectRatio,
|
|
child: _isCalibrating
|
|
? Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ClipRect(
|
|
child: Builder(
|
|
builder: (context) {
|
|
return Transform(
|
|
transform: Matrix4.identity()
|
|
..setTranslationRaw(
|
|
widget.cropOffset?.dx ?? 0.0,
|
|
widget.cropOffset?.dy ?? 0.0,
|
|
0.0,
|
|
)
|
|
..rotateZ(
|
|
(provider.cropRotation) *
|
|
(math.pi / 180),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Image.file(
|
|
File(provider.imagePath!),
|
|
fit: BoxFit.contain,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
TargetCalibration(
|
|
key: _calibrationKey,
|
|
initialCenterX: provider.targetCenterX,
|
|
initialCenterY: provider.targetCenterY,
|
|
initialRadius: provider.targetRadius,
|
|
initialInnerRadius: provider.targetInnerRadius,
|
|
initialRingCount: provider.ringCount,
|
|
initialRingRadii: provider.ringRadii,
|
|
targetType: provider.targetType!,
|
|
onCalibrationChanged:
|
|
(
|
|
centerX,
|
|
centerY,
|
|
innerRadius,
|
|
radius,
|
|
ringCount, {
|
|
ringRadii,
|
|
}) {
|
|
provider.adjustTargetPosition(
|
|
centerX,
|
|
centerY,
|
|
innerRadius,
|
|
radius,
|
|
ringCount: ringCount,
|
|
ringRadii: ringRadii,
|
|
zoomScale: 1.0,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
)
|
|
: _buildReadOnlyPlotImage(context, provider),
|
|
),
|
|
|
|
if (!_isCalibrating)
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
|
child: Column(
|
|
children: [
|
|
Card(
|
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
|
child: ListTile(
|
|
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,
|
|
),
|
|
// Retour vers la calibration : on réinitialise le zoom.
|
|
onTap: () => _enterCalibration(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
ScoreCard(
|
|
totalScore: provider.totalScore,
|
|
shotCount: provider.shotCount,
|
|
scoreResult: provider.scoreResult,
|
|
targetType: provider.targetType!,
|
|
// Cumul de la session : cibles déjà validées + cible
|
|
// en cours (absent hors session).
|
|
sessionTotalScore: sessionProvider.isSessionActive
|
|
? sessionProvider.totalSessionScore +
|
|
provider.totalScore
|
|
: null,
|
|
sessionTargetCount:
|
|
sessionProvider.targetCount + 1,
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (provider.groupingResult != null &&
|
|
provider.shotCount > 1)
|
|
GroupingStats(
|
|
groupingResult: provider.groupingResult!,
|
|
targetCenterX: provider.targetCenterX,
|
|
targetCenterY: provider.targetCenterY,
|
|
),
|
|
const SizedBox(height: 50),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
|
child: Column(
|
|
children: [
|
|
const Text(
|
|
'Ajustement precis (pixel par pixel)',
|
|
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();
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Instructions de calibration',
|
|
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',
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
const Text('Rayon: '),
|
|
Text(
|
|
'${(provider.targetRadius * 100).toStringAsFixed(1)}%',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: Align(
|
|
alignment: _isAtBottom
|
|
? Alignment.bottomCenter
|
|
: Alignment.bottomRight,
|
|
child: Padding(
|
|
padding: _isAtBottom
|
|
? EdgeInsets.zero
|
|
: const EdgeInsets.all(16.0),
|
|
child: _buildBottomAction(context, provider),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInstructionItem(IconData icon, String text) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 16, color: AppTheme.primaryColor),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Affichage du plotting en LECTURE SEULE.
|
|
///
|
|
/// 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 GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => _openImpactEditor(context.read<AnalysisProvider>()),
|
|
child: Stack(
|
|
children: [
|
|
Image.file(
|
|
File(provider.imagePath!),
|
|
key: _imageKey,
|
|
fit: BoxFit.contain,
|
|
),
|
|
Positioned.fill(
|
|
child: TargetOverlay(
|
|
targetCenterX: provider.targetCenterX,
|
|
targetCenterY: provider.targetCenterY,
|
|
targetRadius: provider.targetRadius,
|
|
targetType: provider.targetType!,
|
|
shots: provider.shots,
|
|
showRings: true,
|
|
zoomScale: 1.0,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Session Terminee'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Nombre de tirs: ${provider.shotCount}'),
|
|
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: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
// CORRECTION : on repart aussi de l'image SOURCE non rognée ici.
|
|
final path = _backCropImagePath(provider);
|
|
final type = provider.targetType!;
|
|
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => CropScreen(
|
|
imagePath: path,
|
|
targetType: type,
|
|
initialScale: widget.cropScale,
|
|
initialOffset: widget.cropOffset,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
child: const Text('ANNULER'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
try {
|
|
final sessionProvider = context.read<SessionProvider>();
|
|
final analysis = await provider.saveSession(
|
|
sessionId: sessionProvider.activeSessionId,
|
|
weaponName: sessionProvider.currentWeapon,
|
|
weaponId: sessionProvider.currentWeaponId,
|
|
distance: sessionProvider.distance,
|
|
date: sessionProvider.sessionDate,
|
|
);
|
|
|
|
sessionProvider.addAnalysis(analysis);
|
|
|
|
if (context.mounted) {
|
|
Navigator.pop(context);
|
|
Navigator.pushAndRemoveUntil(
|
|
context,
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: const Text('AJOUTER UNE CIBLE'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
try {
|
|
final sessionProvider = context.read<SessionProvider>();
|
|
await provider.saveSession(
|
|
sessionId: sessionProvider.activeSessionId,
|
|
weaponName: sessionProvider.currentWeapon,
|
|
weaponId: sessionProvider.currentWeaponId,
|
|
distance: sessionProvider.distance,
|
|
date: sessionProvider.sessionDate,
|
|
);
|
|
|
|
if (context.mounted) {
|
|
sessionProvider.endSession();
|
|
Navigator.pop(context);
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
// Fin de session : on atterrit sur les statistiques.
|
|
openMainTab(mainTabStats);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur: $e'),
|
|
backgroundColor: AppTheme.errorColor,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: const Text('TERMINER TOUT'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |