- Supprime 5 services inatteignables depuis l'UI (~3 000 lignes) : distortion_correction, image_processing, target_detection, opencv_impact_detection, target_rectify - AnalysisProvider allégé (835 -> ~360 lignes) : retrait de la détection par références, de la détection auto d'impacts, du workflow distorsion et du doublon moveShot - Retire les dépendances inutilisées google_mlkit_object_detection et google_mlkit_document_scanner du pubspec - Le bouton ↻ du Plotting efface désormais tous les impacts en un clic (clearShots) sans relancer la détection auto ni toucher la calibration - Nettoie les paramètres morts de TargetOverlay (referenceImpacts, onAddShot), le flag _isSelectingReferences, _buildActionButtons vide et le résidu _detectionTimer de capture_screen - Supprime le dossier tests/ (brouillons d'expérimentation OpenCV) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
825 lines
31 KiB
Dart
825 lines
31 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 '../../core/constants/app_constants.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../data/models/target_type.dart';
|
|
import '../../data/models/shot.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;
|
|
|
|
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);
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients) return;
|
|
final isBottom =
|
|
_scrollController.position.pixels >=
|
|
_scrollController.position.maxScrollExtent - 20;
|
|
if (isBottom != _isAtBottom) {
|
|
setState(() {
|
|
_isAtBottom = isBottom;
|
|
});
|
|
}
|
|
}
|
|
|
|
@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.
|
|
///
|
|
/// 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).
|
|
void _enterCalibration() {
|
|
_transformationController.value = Matrix4.identity();
|
|
_currentZoomScale = 1.0;
|
|
setState(() => _isCalibrating = true);
|
|
}
|
|
|
|
/// 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 : si l'utilisateur a validé (résultat true) on bascule en mode
|
|
/// Plotting (lecture seule) ; sinon on repasse en calibration.
|
|
Future<void> _openImpactEditor(AnalysisProvider provider) async {
|
|
final validated = await Navigator.of(context).push<bool>(
|
|
MaterialPageRoute(
|
|
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
|
|
value: provider,
|
|
child: const ImpactEditorScreen(),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (validated == true) {
|
|
setState(() {
|
|
_isCalibrating = false;
|
|
});
|
|
} else {
|
|
_enterCalibration();
|
|
}
|
|
}
|
|
|
|
/// 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!;
|
|
}
|
|
|
|
@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: [
|
|
// 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...')),
|
|
);
|
|
|
|
final success = await p.exportToAiBackend();
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
SingleChildScrollView(
|
|
controller: _scrollController,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (provider.state == AnalysisState.loading)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8.0),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
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,
|
|
)
|
|
..scale(1.0, 1.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.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(
|
|
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!,
|
|
),
|
|
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: _isCalibrating
|
|
? const SizedBox.shrink()
|
|
: FloatingActionButton.extended(
|
|
onPressed: () =>
|
|
_showSaveSessionDialog(context, provider),
|
|
backgroundColor: AppTheme.primaryColor,
|
|
icon: const Icon(Icons.save),
|
|
label: const Text('TERMINER LA SESSION'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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.
|
|
///
|
|
/// 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.
|
|
Widget _buildReadOnlyPlotImage(
|
|
BuildContext context,
|
|
AnalysisProvider provider,
|
|
) {
|
|
return InteractiveViewer(
|
|
transformationController: _transformationController,
|
|
minScale: 1.0,
|
|
maxScale: 10.0,
|
|
boundaryMargin: const EdgeInsets.all(80),
|
|
panEnabled: true,
|
|
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: _currentZoomScale,
|
|
// Lecture seule : tap sur impact -> détails (consultation).
|
|
onShotTapped: (shot) =>
|
|
_showShotDetails(context, provider, shot),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showShotDetails(
|
|
BuildContext context,
|
|
AnalysisProvider provider,
|
|
Shot shot,
|
|
) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (context) => Container(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Impact #${provider.shots.indexOf(shot) + 1}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
|
),
|
|
Text(
|
|
'ID: ${shot.id}',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ListTile(
|
|
leading: const Icon(Icons.score),
|
|
title: const Text('Modifier le score'),
|
|
trailing: DropdownButton<int>(
|
|
value: shot.score.clamp(0, 10),
|
|
items: List.generate(11, (index) => index)
|
|
.map(
|
|
(s) => DropdownMenuItem(
|
|
value: s,
|
|
child: Text(
|
|
'$s',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (newScore) {
|
|
if (newScore != null) {
|
|
provider.updateShotScore(shot.id, newScore);
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {
|
|
provider.removeShot(shot.id);
|
|
Navigator.pop(context);
|
|
},
|
|
icon: const Icon(Icons.delete, color: Colors.red),
|
|
label: const Text(
|
|
'SUPPRIMER',
|
|
style: TextStyle(color: Colors.red),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 ?'),
|
|
],
|
|
),
|
|
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);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur: $e'),
|
|
backgroundColor: AppTheme.errorColor,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: const Text('TERMINER TOUT'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |