Files
impact/lib/features/analysis/analysis_provider.dart
qguillaume a3167fc2ba fix: filtre historique, fuite mémoire OpenCV, export IA, tests
- Historique : le filtre par type de cible est désormais appliqué au
  chargement ; correction du piège PopupMenuItem(value: null) qui
  empêchait l'option « Tous » de réinitialiser le filtre ; icône
  colorée quand un filtre est actif
- OpenCV : libération des Mat natifs (img, gray, blurred, circles)
  dans un finally — detectTarget tourne toutes les secondes pendant
  l'aperçu caméra et faisait grimper la mémoire native en continu
- Export IA : distance, arme et id de session réels transmis depuis
  SessionProvider au lieu des placeholders (25 m / "Unknown")
- Tests : remplacement du test widget cassé (BullyApp sans providers)
  par 14 tests qui passent — calcul de score concentrique (centre,
  hors cible, ratio d'image, anneaux personnalisés), agrégation des
  scores, analyse de groupement, et rendu du widget StatsCard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:16:49 +02:00

384 lines
12 KiB
Dart

/// 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<double>? _ringRadii; // Individual ring radii multipliers
double _imageAspectRatio = 1.0; // width / height
// Shots
List<Shot> _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<double>? get ringRadii =>
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
double get imageAspectRatio => _imageAspectRatio;
List<Shot> 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<void> 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<double>? 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<bool> 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<TargetAnalysis> 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();
}
}
}