/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier). /// /// Gère le workflow complet d'analyse : chargement d'image, gestion des /// impacts placés manuellement, calcul des scores, analyse de groupement /// et sauvegarde des sessions. library; import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import '../../data/models/target_analysis.dart'; import '../../data/models/shot.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/ai_export_service.dart'; enum AnalysisState { initial, loading, success, error } class AnalysisProvider extends ChangeNotifier { final ScoreCalculatorService _scoreCalculatorService; final GroupingAnalyzerService _groupingAnalyzerService; final SessionRepository _sessionRepository; final Uuid _uuid = const Uuid(); AnalysisProvider({ required ScoreCalculatorService scoreCalculatorService, required GroupingAnalyzerService groupingAnalyzerService, required SessionRepository sessionRepository, }) : _scoreCalculatorService = scoreCalculatorService, _groupingAnalyzerService = groupingAnalyzerService, _sessionRepository = sessionRepository; AnalysisState _state = AnalysisState.initial; String? _errorMessage; String? _imagePath; TargetType? _targetType; // AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop double _cropRotation = 0.0; // Target calibration double _targetCenterX = 0.5; double _targetCenterY = 0.5; double _targetRadius = 0.4; double _targetInnerRadius = 0.04; int _ringCount = 10; List? _ringRadii; // Individual ring radii multipliers double _imageAspectRatio = 1.0; // width / height // Shots List _shots = []; // Score results ScoreResult? _scoreResult; // Grouping results GroupingResult? _groupingResult; // Getters AnalysisState get state => _state; String? get errorMessage => _errorMessage; String? get imagePath => _imagePath; TargetType? get targetType => _targetType; double get cropRotation => _cropRotation; // Getter pour le Plotting double get targetCenterX => _targetCenterX; double get targetCenterY => _targetCenterY; double get targetRadius => _targetRadius; double get targetInnerRadius => _targetInnerRadius; int get ringCount => _ringCount; List? get ringRadii => _ringRadii != null ? List.unmodifiable(_ringRadii!) : null; double get imageAspectRatio => _imageAspectRatio; List get shots => List.unmodifiable(_shots); ScoreResult? get scoreResult => _scoreResult; GroupingResult? get groupingResult => _groupingResult; int get totalScore => _scoreResult?.totalScore ?? 0; int get shotCount => _shots.length; /// Modifie et mémorise la rotation de l'image pour le Plotting void setCropRotation(double rotation) { _cropRotation = rotation; notifyListeners(); } /// Charge l'image et initialise les paramètres de cible par défaut. /// Le placement des impacts et la calibration se font ensuite manuellement. Future analyzeImage( String imagePath, TargetType targetType, { Offset? manualCenter, }) async { _state = AnalysisState.loading; _imagePath = imagePath; _targetType = targetType; _errorMessage = null; notifyListeners(); try { // Load image to get dimensions final file = File(imagePath); final bytes = await file.readAsBytes(); final codec = await ui.instantiateImageCodec(bytes); final frame = await codec.getNextFrame(); _imageAspectRatio = frame.image.width / frame.image.height; frame.image.dispose(); _targetCenterX = manualCenter?.dx ?? 0.5; _targetCenterY = manualCenter?.dy ?? 0.5; _targetRadius = 0.4; _targetInnerRadius = 0.04; _shots = []; _state = AnalysisState.success; notifyListeners(); } catch (e) { _state = AnalysisState.error; _errorMessage = 'Erreur d\'analyse: $e'; notifyListeners(); } } /// Add a manual shot void addShot(double x, double y) { final score = _calculateShotScore(x, y); final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: ''); _shots.add(shot); _recalculateScores(); _recalculateGrouping(); notifyListeners(); } /// Efface tous les impacts en un clic (bouton ↻ de l'écran Plotting). /// La calibration (centre, rayon, anneaux) n'est pas touchée. void clearShots() { _shots.clear(); _recalculateScores(); _recalculateGrouping(); notifyListeners(); } /// Remove a shot void removeShot(String shotId) { _shots.removeWhere((shot) => shot.id == shotId); _recalculateScores(); _recalculateGrouping(); notifyListeners(); } /// Update a shot's score manually void updateShotScore(String shotId, int newScore) { final index = _shots.indexWhere((shot) => shot.id == shotId); if (index == -1) return; _shots[index] = _shots[index].copyWith(score: newScore); _recalculateScores(); notifyListeners(); } /// Adjust target position void adjustTargetPosition( double centerX, double centerY, double innerRadius, double radius, { int? ringCount, List? ringRadii, double zoomScale = 1.0, Offset offset = Offset.zero, }) { _targetCenterX = (centerX - offset.dx) / zoomScale; _targetCenterY = (centerY - offset.dy) / zoomScale; _targetRadius = radius / zoomScale; _targetInnerRadius = innerRadius / zoomScale; if (ringCount != null) { _ringCount = ringCount; } // CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement ! _ringRadii = ringRadii; // Recalculate all shot scores based on new target position _shots = _shots.map((shot) { final newScore = _calculateShotScore(shot.x, shot.y); return shot.copyWith(score: newScore); }).toList(); _recalculateScores(); notifyListeners(); } int _calculateShotScore(double x, double y) { if (_targetType == TargetType.concentric) { return _scoreCalculatorService.calculateConcentricScore( shotX: x, shotY: y, targetCenterX: _targetCenterX, targetCenterY: _targetCenterY, targetRadius: _targetRadius, ringCount: _ringCount, imageAspectRatio: _imageAspectRatio, ringRadii: _ringRadii, ); } else { return _scoreCalculatorService.calculateSilhouetteScore( shotX: x, shotY: y, targetCenterX: _targetCenterX, targetCenterY: _targetCenterY, targetWidth: _targetRadius * 0.8, targetHeight: _targetRadius * 2, ); } } void _recalculateScores() { if (_targetType == null) return; _scoreResult = _scoreCalculatorService.calculateScores( shots: _shots, targetType: _targetType!, targetCenterX: _targetCenterX, targetCenterY: _targetCenterY, targetRadius: _targetRadius, ringCount: _ringCount, imageAspectRatio: _imageAspectRatio, ringRadii: _ringRadii, ); } void _recalculateGrouping() { _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); } /// Exporte l'image et le json vers le backend IA. /// [sessionId], [distance] et [weapon] proviennent du SessionProvider de /// l'écran appelant, pour que le dataset contienne les vraies métadonnées. Future exportToAiBackend({ String? sessionId, int? distance, String? weapon, }) async { if (_imagePath == null || _targetType == null) { _errorMessage = "Impossible d'export : image ou type de cible manquant."; notifyListeners(); return false; } final service = AiExportService(); _state = AnalysisState.loading; notifyListeners(); final success = await service.exportData( imagePath: _imagePath!, sessionId: sessionId ?? 'export', targetType: _targetType!, targetCenterX: _targetCenterX, targetCenterY: _targetCenterY, targetRadius: _targetRadius, shots: _shots, distanceMeters: distance ?? 25, weaponName: weapon ?? 'Unknown', ); _state = AnalysisState.success; if (!success) { _errorMessage = "Échec de l'export vers le serveur IA."; } notifyListeners(); return success; } /// Save the session Future saveSession({ String? notes, String? sessionId, String? weaponName, String? weaponId, int? distance, DateTime? date, }) async { if (_imagePath == null || _targetType == null) { throw Exception('Cannot save: missing image or target type'); } final targetAnalysis = await _sessionRepository.prepareTargetAnalysis( imagePath: _imagePath!, sessionId: sessionId ?? 'standalone', targetType: _targetType!, shots: _shots, totalScore: totalScore, groupingDiameter: _groupingResult?.diameter, groupingCenterX: _groupingResult?.centerX, groupingCenterY: _groupingResult?.centerY, notes: notes, targetCenterX: _targetCenterX, targetCenterY: _targetCenterY, targetRadius: _targetRadius, ); if (sessionId != null && sessionId != 'standalone') { final existingSession = await _sessionRepository.getSession(sessionId); if (existingSession != null) { await _sessionRepository.addAnalysisToSession( sessionId, targetAnalysis, ); } else { // CORRECTION : Utilise la date injectée plutôt que DateTime.now() await _sessionRepository.createSession( id: sessionId, weapon: weaponName ?? 'Inconnue', weaponId: weaponId, maxShotsPerTarget: shotCount, analyses: [targetAnalysis], notes: notes, distance: distance ?? 25, date: date ?? DateTime.now(), ); } } else { // CORRECTION : S'applique aussi au flux alternatif sans ID préalable await _sessionRepository.createSession( weapon: weaponName ?? 'Inconnue', weaponId: weaponId, maxShotsPerTarget: shotCount, analyses: [targetAnalysis], notes: notes, distance: distance ?? 25, date: date ?? DateTime.now(), ); } notifyListeners(); return targetAnalysis; } /// Reset the provider void reset() { _state = AnalysisState.initial; _errorMessage = null; _imagePath = null; _targetType = null; _cropRotation = 0.0; _targetCenterX = 0.5; _targetCenterY = 0.5; _targetRadius = 0.4; _targetInnerRadius = 0.04; _ringCount = 10; _ringRadii = null; _imageAspectRatio = 1.0; _shots = []; _scoreResult = null; _groupingResult = null; notifyListeners(); } /// Met à jour la position d'un impact de tir en direct (Glisser-Déposer). void updateShotPosition(String shotId, double newX, double newY) { final index = _shots.indexWhere((s) => s.id == shotId); if (index != -1) { // 1. On calcule dynamiquement le nouveau score de la zone survolée final newScore = _calculateShotScore(newX, newY); // 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés _shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore); // 3. On applique les deux méthodes natives de recalcul de ton application _recalculateScores(); _recalculateGrouping(); // 4. On demande à l'écran de se redessiner en direct notifyListeners(); } } }