1030 lines
37 KiB
Dart
1030 lines
37 KiB
Dart
/// Écran principal de Synthèse 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 (Synthèse), et
|
|
/// c'est de là que l'on termine la session. L'ajout des impacts, lui, se fait
|
|
/// dans l'éditeur d'impacts plein écran.
|
|
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 '../../services/ai_export_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 Synthèse 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 la Synthèse. L'éditeur d'impacts n'est ouvert que
|
|
/// depuis la Synthèse : 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 Synthèse.
|
|
///
|
|
/// Rien n'indiquait comment ajouter un impact une fois la calibration
|
|
/// validée : ce rappel pointe vers le geste (toucher la cible).
|
|
Widget _buildSyntheseHint(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 synthèse : 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 la Synthèse.
|
|
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' : 'Synthèse';
|
|
final title = targetNumber != null
|
|
? '$titlePrefix - Cible $targetNumber'
|
|
: (_isCalibrating ? 'Calibration' : 'Synthèse 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 Synthèse -> 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
|
|
_buildSyntheseHint(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 de la synthèse 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showSaveSessionDialog(
|
|
BuildContext context,
|
|
AnalysisProvider provider,
|
|
) async {
|
|
// L'option « Participer à l'entraînement IA » (Paramètres) est lue AVANT
|
|
// d'ouvrir la popup : elle décide de la présence du bouton d'export.
|
|
final canExport =
|
|
await WalletIdentityService().isUploadEnabled() &&
|
|
provider.state == AnalysisState.success;
|
|
|
|
if (!context.mounted) return;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
// Protège des petits écrans / grandes polices : la popup défile au
|
|
// lieu de déborder.
|
|
scrollable: true,
|
|
// En-tête bleu pleine largeur : le titre occupe toute la bande, d'où
|
|
// les paddings mis à zéro.
|
|
titlePadding: EdgeInsets.zero,
|
|
contentPadding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
title: Container(
|
|
width: double.infinity,
|
|
color: AppTheme.primaryColor,
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.flag, color: Colors.white),
|
|
SizedBox(width: 10),
|
|
Text(
|
|
'Session terminée',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Les boutons sont dans le contenu (et non dans `actions`) pour être
|
|
// tous à la même largeur, alignés les uns sous les autres.
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildDialogRecap(context, provider),
|
|
const SizedBox(height: 16),
|
|
const Text('Voulez-vous enregistrer cette session ?'),
|
|
const SizedBox(height: 16),
|
|
_buildDialogButton(
|
|
icon: const Icon(Icons.add_a_photo, color: Colors.white),
|
|
label: 'AJOUTER UNE CIBLE',
|
|
color: AppTheme.secondaryColor,
|
|
onPressed: () => _saveAndAddTarget(context, provider),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildDialogButton(
|
|
icon: const Icon(Icons.save, color: Colors.white),
|
|
label: 'TERMINER TOUT',
|
|
color: AppTheme.primaryColor,
|
|
onPressed: () => _finishSession(context, provider),
|
|
),
|
|
// Bouton d'export : uniquement si l'entraînement IA est autorisé.
|
|
if (canExport) ...[
|
|
const SizedBox(height: 8),
|
|
_buildDialogButton(
|
|
icon: Image.asset(
|
|
'assets/icons/cloud_save.png',
|
|
width: 24,
|
|
height: 24,
|
|
// L'icône est un trait noir : on la recolore en blanc pour
|
|
// qu'elle ressorte sur le bouton.
|
|
color: Colors.white,
|
|
),
|
|
label: 'TERMINER ET CONTRIBUER À L\'IA',
|
|
color: AppTheme.warningColor,
|
|
onPressed: () =>
|
|
_finishSession(context, provider, export: true),
|
|
),
|
|
],
|
|
const SizedBox(height: 4),
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Rappel chiffré (tirs / score) en tête de la popup de fin de session.
|
|
Widget _buildDialogRecap(BuildContext context, AnalysisProvider provider) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primaryColor.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_buildRecapValue(context, '${provider.shotCount}', 'Tirs'),
|
|
_buildRecapValue(context, '${provider.totalScore}', 'Score total'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRecapValue(BuildContext context, String value, String label) {
|
|
return Column(
|
|
children: [
|
|
Text(
|
|
value,
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: AppTheme.primaryColor,
|
|
),
|
|
),
|
|
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Bouton pleine largeur de la popup, aux couleurs du thème.
|
|
Widget _buildDialogButton({
|
|
required Widget icon,
|
|
required String label,
|
|
required Color color,
|
|
required VoidCallback onPressed,
|
|
}) {
|
|
return ElevatedButton.icon(
|
|
icon: icon,
|
|
label: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: color,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
onPressed: onPressed,
|
|
);
|
|
}
|
|
|
|
/// Enregistre la cible courante et enchaîne sur une nouvelle capture.
|
|
Future<void> _saveAndAddTarget(
|
|
BuildContext context,
|
|
AnalysisProvider provider,
|
|
) 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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clôture la session et revient sur l'onglet Statistiques.
|
|
///
|
|
/// Avec [export], la cible est en plus envoyée au backend d'entraînement IA.
|
|
/// L'enregistrement local reste prioritaire : un échec d'export n'empêche
|
|
/// jamais la session d'être sauvegardée.
|
|
Future<void> _finishSession(
|
|
BuildContext context,
|
|
AnalysisProvider provider, {
|
|
bool export = false,
|
|
}) async {
|
|
// Messenger et session capturés AVANT les await : le contexte de la popup
|
|
// ne sera plus valide ensuite.
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final sessionProvider = context.read<SessionProvider>();
|
|
|
|
try {
|
|
await provider.saveSession(
|
|
sessionId: sessionProvider.activeSessionId,
|
|
weaponName: sessionProvider.currentWeapon,
|
|
weaponId: sessionProvider.currentWeaponId,
|
|
distance: sessionProvider.distance,
|
|
date: sessionProvider.sessionDate,
|
|
);
|
|
|
|
AiExportResult? exportResult;
|
|
if (export) {
|
|
messenger.showSnackBar(
|
|
const SnackBar(content: Text('Exportation vers le serveur IA en cours...')),
|
|
);
|
|
exportResult = await provider.exportToAiBackend(
|
|
sessionId: sessionProvider.activeSessionId,
|
|
distance: sessionProvider.distance,
|
|
caliber: sessionProvider.currentWeaponCaliber,
|
|
);
|
|
messenger.hideCurrentSnackBar();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (exportResult != null) {
|
|
if (exportResult.isBanned) {
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.block, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'Participation IA suspendue : ${exportResult.reason ?? "Non-respect des règles"}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: AppTheme.errorColor,
|
|
duration: const Duration(seconds: 6),
|
|
),
|
|
);
|
|
} else if (exportResult.isSuccess) {
|
|
final targetStatus = exportResult.targetValidation?['status'];
|
|
final isCertified = targetStatus == 'VALID';
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
Icon(
|
|
isCertified ? Icons.verified : Icons.cloud_done,
|
|
color: Colors.white,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
isCertified
|
|
? 'Export réussi ! Cible certifiée par l\'IA.'
|
|
: exportResult.message,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: AppTheme.successColor,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
} else {
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.warning_amber_rounded, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text('Échec de l\'export : ${exportResult.message}'),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: AppTheme.errorColor,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur: $e'),
|
|
backgroundColor: AppTheme.errorColor,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} |