Compare commits
43 Commits
774dbfcf40
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e27d01f17 | ||
|
|
8dd78f6b80 | ||
|
|
23c3bb178f | ||
|
|
18e591f3fc | ||
|
|
13cf5b70e0 | ||
|
|
f0b15941cf | ||
|
|
9f9f9beb84 | ||
|
|
c49c8b8c73 | ||
|
|
61b5fcfe22 | ||
|
|
f65c5e6a3f | ||
|
|
d487c98e3d | ||
|
|
9cb37fc303 | ||
|
|
3ae9ceebc1 | ||
|
|
251d4bb599 | ||
|
|
ccc6eb609a | ||
|
|
9e0427f750 | ||
|
|
a5d0251545 | ||
|
|
730629a031 | ||
|
|
4d2ca2c94f | ||
|
|
ab45786d0c | ||
|
|
95e62abe47 | ||
|
|
04c1204116 | ||
|
|
78c9fc96c9 | ||
|
|
e570bd4296 | ||
|
|
02c0d449e4 | ||
|
|
eabe7511bd | ||
|
|
620e12a1fd | ||
|
|
622f2936de | ||
|
|
814f778e6c | ||
|
|
2f493ef622 | ||
|
|
33e1522df0 | ||
|
|
653264c6a1 | ||
|
|
750732eafe | ||
|
|
f40bfc0ba7 | ||
|
|
b721583e98 | ||
|
|
18c01c3767 | ||
|
|
0af6a0d8e2 | ||
|
|
fbd631c8e4 | ||
|
|
3088c5ee91 | ||
|
|
fe6d52902b | ||
|
|
48b8313be8 | ||
|
|
a9ff826246 | ||
|
|
f149fe092b |
@@ -37,6 +37,9 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="docscanner" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import '../models/session.dart';
|
||||
@@ -123,10 +124,10 @@ class DatabaseHelper {
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
if (oldVersion < 2) {
|
||||
// Migration from single-target sessions to multi-target sessions
|
||||
|
||||
|
||||
// 1. Rename existing sessions table to a temporary name
|
||||
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} RENAME TO sessions_old');
|
||||
|
||||
|
||||
// 2. Create the new sessions table
|
||||
await db.execute('''
|
||||
CREATE TABLE ${AppConstants.sessionsTable} (
|
||||
@@ -220,7 +221,7 @@ class DatabaseHelper {
|
||||
// 6. Cleanup
|
||||
await db.execute('DROP TABLE sessions_old');
|
||||
await db.execute('DROP TABLE shots_old');
|
||||
|
||||
|
||||
// 7. Re-create indexes
|
||||
await db.execute('''
|
||||
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
|
||||
@@ -285,9 +286,14 @@ class DatabaseHelper {
|
||||
Future<int> insertSession(Session session) async {
|
||||
final db = await database;
|
||||
return await db.transaction((txn) async {
|
||||
// INTERCEPTION ET FIX DE LA DATE ICI :
|
||||
// On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet
|
||||
final Map<String, dynamic> sessionMap = Map.from(session.toMap());
|
||||
sessionMap['created_at'] = session.createdAt.toIso8601String();
|
||||
|
||||
await txn.insert(
|
||||
AppConstants.sessionsTable,
|
||||
session.toMap(),
|
||||
sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct !
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
@@ -447,7 +453,7 @@ class DatabaseHelper {
|
||||
|
||||
Future<int> updateWeapon(Weapon weapon) async {
|
||||
final db = await database;
|
||||
|
||||
|
||||
// Start a transaction to ensure both updates succeed
|
||||
return await db.transaction((txn) async {
|
||||
// 1. Update weapon in armory
|
||||
@@ -472,7 +478,7 @@ class DatabaseHelper {
|
||||
|
||||
Future<int> deleteWeapon(String id) async {
|
||||
final db = await database;
|
||||
|
||||
|
||||
return await db.transaction((txn) async {
|
||||
// 1. Unlink sessions from this weapon but KEEP the name (gravé à jamais)
|
||||
await txn.update(
|
||||
@@ -538,4 +544,4 @@ class DatabaseHelper {
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class SessionRepository {
|
||||
required List<TargetAnalysis> analyses,
|
||||
String? notes,
|
||||
int distance = 25,
|
||||
DateTime? date,
|
||||
}) async {
|
||||
final session = Session(
|
||||
id: id ?? _uuid.v4(),
|
||||
@@ -34,7 +35,7 @@ class SessionRepository {
|
||||
weaponId: weaponId,
|
||||
maxShotsPerTarget: maxShotsPerTarget,
|
||||
analyses: analyses,
|
||||
createdAt: DateTime.now(),
|
||||
createdAt: date ?? DateTime.now(),
|
||||
notes: notes,
|
||||
distance: distance,
|
||||
);
|
||||
|
||||
@@ -39,17 +39,20 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
DistortionCorrectionService? distortionService,
|
||||
OpenCVTargetService? opencvTargetService,
|
||||
}) : _detectionService = detectionService,
|
||||
_scoreCalculatorService = scoreCalculatorService,
|
||||
_groupingAnalyzerService = groupingAnalyzerService,
|
||||
_sessionRepository = sessionRepository,
|
||||
_distortionService = distortionService ?? DistortionCorrectionService(),
|
||||
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
|
||||
_scoreCalculatorService = scoreCalculatorService,
|
||||
_groupingAnalyzerService = groupingAnalyzerService,
|
||||
_sessionRepository = sessionRepository,
|
||||
_distortionService = distortionService ?? DistortionCorrectionService(),
|
||||
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
|
||||
|
||||
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 detection results
|
||||
double _targetCenterX = 0.5;
|
||||
double _targetCenterY = 0.5;
|
||||
@@ -82,6 +85,7 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
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;
|
||||
@@ -109,19 +113,25 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
|
||||
String? get displayImagePath =>
|
||||
_distortionCorrectionEnabled && _correctedImagePath != null
|
||||
? _correctedImagePath
|
||||
: _imagePath;
|
||||
? _correctedImagePath
|
||||
: _imagePath;
|
||||
|
||||
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
||||
void setCropRotation(double rotation) {
|
||||
_cropRotation = rotation;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Analyze an image
|
||||
///
|
||||
/// [autoAnalyze] determines if we should run automatic detection immediately.
|
||||
/// If false, only the image is loaded and default target parameters are set.
|
||||
Future<void> analyzeImage(
|
||||
String imagePath,
|
||||
TargetType targetType, {
|
||||
bool autoAnalyze = true,
|
||||
Offset? manualCenter,
|
||||
}) async {
|
||||
String imagePath,
|
||||
TargetType targetType, {
|
||||
bool autoAnalyze = true,
|
||||
Offset? manualCenter,
|
||||
}) async {
|
||||
_state = AnalysisState.loading;
|
||||
_imagePath = imagePath;
|
||||
_targetType = targetType;
|
||||
@@ -152,8 +162,10 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect target and impacts
|
||||
final result = _detectionService.detectTarget(imagePath, targetType);
|
||||
final result = await _detectionService.detectTargetAsync(
|
||||
imagePath,
|
||||
targetType,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
_state = AnalysisState.error;
|
||||
@@ -174,7 +186,7 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
score: impact.suggestedScore,
|
||||
analysisId: '', // Will be set when saving
|
||||
analysisId: '',
|
||||
);
|
||||
}).toList();
|
||||
|
||||
@@ -375,15 +387,15 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
|
||||
final detectedImpacts = _detectionService
|
||||
.detectImpactsWithOpenCVFromReferences(
|
||||
_imagePath!,
|
||||
_targetType!,
|
||||
_targetCenterX,
|
||||
_targetCenterY,
|
||||
_targetRadius,
|
||||
_ringCount,
|
||||
references,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
_imagePath!,
|
||||
_targetType!,
|
||||
_targetCenterX,
|
||||
_targetCenterY,
|
||||
_targetRadius,
|
||||
_ringCount,
|
||||
references,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
|
||||
if (clearExisting) {
|
||||
_shots.clear();
|
||||
@@ -496,23 +508,26 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
|
||||
/// Adjust target position
|
||||
void adjustTargetPosition(
|
||||
double centerX,
|
||||
double centerY,
|
||||
double innerRadius,
|
||||
double radius, {
|
||||
int? ringCount,
|
||||
List<double>? ringRadii,
|
||||
}) {
|
||||
_targetCenterX = centerX;
|
||||
_targetCenterY = centerY;
|
||||
_targetInnerRadius = innerRadius;
|
||||
_targetRadius = radius;
|
||||
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;
|
||||
}
|
||||
if (ringRadii != null) {
|
||||
_ringRadii = ringRadii;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -537,8 +552,7 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
_imagePath = correctedPath;
|
||||
_correctedImagePath = correctedPath;
|
||||
_distortionCorrectionEnabled = true;
|
||||
_imageAspectRatio =
|
||||
1.0; // The corrected image is always square (side x side)
|
||||
_imageAspectRatio = 1.0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -616,18 +630,18 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
// 3. Application immédiate de la transformation (méthode asynchrone)
|
||||
await applyDistortionCorrection();
|
||||
} else {
|
||||
notifyListeners(); // On prévient quand même si pas de correction
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> runFullDistortionWorkflow() async {
|
||||
_state = AnalysisState.loading; // Affiche un spinner sur votre UI
|
||||
_state = AnalysisState.loading;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
calculateDistortion(); // Calcule les paramètres
|
||||
await applyDistortionCorrection(); // Génère le fichier corrigé
|
||||
_distortionCorrectionEnabled = true; // Active l'affichage
|
||||
calculateDistortion();
|
||||
await applyDistortionCorrection();
|
||||
_distortionCorrectionEnabled = true;
|
||||
_state = AnalysisState.success;
|
||||
} catch (e) {
|
||||
_errorMessage = "Erreur de rendu : $e";
|
||||
@@ -683,18 +697,13 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
/// Exporte l'image et le json vers le backend IA
|
||||
Future<bool> exportToAiBackend() async {
|
||||
if (_imagePath == null || _targetType == null) {
|
||||
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
|
||||
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Identifiant d'analyse temporaire
|
||||
final analysisId = _shots.isNotEmpty && _shots.first.analysisId.isNotEmpty
|
||||
? _shots.first.analysisId
|
||||
: 'analysis_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final service = AiExportService();
|
||||
|
||||
final service = AiExportService(); // Local instanciation for simplicity
|
||||
|
||||
_state = AnalysisState.loading;
|
||||
notifyListeners();
|
||||
|
||||
@@ -716,13 +725,14 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Save the session (legacy standalone flow)
|
||||
/// 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');
|
||||
@@ -746,8 +756,12 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
if (sessionId != null && sessionId != 'standalone') {
|
||||
final existingSession = await _sessionRepository.getSession(sessionId);
|
||||
if (existingSession != null) {
|
||||
await _sessionRepository.addAnalysisToSession(sessionId, targetAnalysis);
|
||||
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',
|
||||
@@ -756,9 +770,11 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
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,
|
||||
@@ -766,6 +782,7 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
analyses: [targetAnalysis],
|
||||
notes: notes,
|
||||
distance: distance ?? 25,
|
||||
date: date ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -779,6 +796,7 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
_errorMessage = null;
|
||||
_imagePath = null;
|
||||
_targetType = null;
|
||||
_cropRotation = 0.0;
|
||||
_targetCenterX = 0.5;
|
||||
_targetCenterY = 0.5;
|
||||
_targetRadius = 0.4;
|
||||
@@ -796,4 +814,23 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
_correctedImagePath = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
/// Écran principal d'analyse - Interface centrale de traitement des cibles.
|
||||
/// Écran principal de Plotting et d'analyse - Interface centrale de traitement des cibles.
|
||||
///
|
||||
/// Affiche la cible avec overlay des anneaux et impacts détectés.
|
||||
/// Permet la calibration, l'ajout manuel d'impacts, la détection automatique,
|
||||
/// et le calcul des scores et statistiques de groupement.
|
||||
/// 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';
|
||||
@@ -16,7 +17,6 @@ import '../../data/repositories/session_repository.dart';
|
||||
import '../../services/target_detection_service.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 '../crop/crop_screen.dart';
|
||||
@@ -28,44 +28,79 @@ import 'widgets/grouping_stats.dart';
|
||||
|
||||
class AnalysisScreen extends StatelessWidget {
|
||||
final String imagePath;
|
||||
|
||||
/// Image originale (jamais recadrée), transmise pour pouvoir revenir au
|
||||
/// centrage sans recadrer un résultat déjà recadré.
|
||||
final String? originalImagePath;
|
||||
|
||||
final TargetType targetType;
|
||||
final Offset? targetCenter;
|
||||
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,
|
||||
required this.targetType,
|
||||
this.targetCenter,
|
||||
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) => AnalysisProvider(
|
||||
detectionService: context.read<TargetDetectionService>(),
|
||||
scoreCalculatorService: context.read<ScoreCalculatorService>(),
|
||||
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
|
||||
sessionRepository: context.read<SessionRepository>(),
|
||||
)..analyzeImage(imagePath, targetType, autoAnalyze: false, manualCenter: targetCenter),
|
||||
create: (context) {
|
||||
final p = AnalysisProvider(
|
||||
detectionService: context.read<TargetDetectionService>(),
|
||||
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,
|
||||
autoAnalyze: false,
|
||||
manualCenter: manualCenterOffset,
|
||||
);
|
||||
return p;
|
||||
},
|
||||
child: _AnalysisScreenContent(
|
||||
originalImagePath: originalImagePath ?? imagePath,
|
||||
cropScale: cropScale,
|
||||
cropOffset: cropOffset,
|
||||
cropRotation: cropRotation, // Envoyé à la structure d'affichage
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnalysisScreenContent extends StatefulWidget {
|
||||
final String originalImagePath;
|
||||
final double? cropScale;
|
||||
final Offset? cropOffset;
|
||||
final double? cropRotation;
|
||||
|
||||
const _AnalysisScreenContent({
|
||||
required this.originalImagePath,
|
||||
this.cropScale,
|
||||
this.cropOffset,
|
||||
this.cropRotation,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -73,16 +108,20 @@ class _AnalysisScreenContent extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
final GlobalKey<TargetCalibrationState> _calibrationKey = GlobalKey<TargetCalibrationState>();
|
||||
bool _isCalibrating = false;
|
||||
final GlobalKey<TargetCalibrationState> _calibrationKey =
|
||||
GlobalKey<TargetCalibrationState>();
|
||||
|
||||
// Forcé à TRUE pour démarrer sur l'ajustement des cercles
|
||||
bool _isCalibrating = true;
|
||||
bool _isSelectingReferences = false;
|
||||
bool _isFullscreenEditMode = false;
|
||||
bool _isAtBottom = false;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TransformationController _transformationController =
|
||||
TransformationController();
|
||||
TransformationController();
|
||||
final GlobalKey _imageKey = GlobalKey();
|
||||
double _currentZoomScale = 1.0;
|
||||
String? _movingShotId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -93,10 +132,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
// Detect if we are near the bottom (within 20 pixels of the specific spacing we added)
|
||||
final isBottom =
|
||||
_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 20;
|
||||
_scrollController.position.maxScrollExtent - 20;
|
||||
if (isBottom != _isAtBottom) {
|
||||
setState(() {
|
||||
_isAtBottom = isBottom;
|
||||
@@ -122,13 +160,34 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Revient au Centrage en repartant de l'image ORIGINALE.
|
||||
/// On ne restaure que la rotation (jamais le zoom) → pas de cumul.
|
||||
void _goBackToCrop(AnalysisProvider provider) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CropScreen(
|
||||
imagePath: widget.originalImagePath,
|
||||
originalImagePath: widget.originalImagePath,
|
||||
targetType: provider.targetType!,
|
||||
initialRotation: provider.cropRotation,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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' : 'Analyse';
|
||||
final title = targetNumber != null ? '$titlePrefix - Cible $targetNumber' : ( _isCalibrating ? 'Calibration' : 'Analyse de Tir');
|
||||
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(
|
||||
@@ -137,26 +196,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
if (_isCalibrating) {
|
||||
setState(() => _isCalibrating = false);
|
||||
} else if (_isSelectingReferences) {
|
||||
setState(() => _isSelectingReferences = false);
|
||||
} else {
|
||||
// Return to crop screen instead of popping to capture
|
||||
final provider = context.read<AnalysisProvider>();
|
||||
final path = provider.imagePath!;
|
||||
final type = provider.targetType!;
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CropScreen(
|
||||
imagePath: path,
|
||||
targetType: type,
|
||||
initialScale: widget.cropScale,
|
||||
initialOffset: widget.cropOffset,
|
||||
),
|
||||
),
|
||||
);
|
||||
_goBackToCrop(provider);
|
||||
} else {
|
||||
setState(() => _isCalibrating = true);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -164,14 +207,20 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
if (!_isCalibrating && !_isSelectingReferences)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => provider.analyzeImage(context.read<AnalysisProvider>().imagePath!, context.read<AnalysisProvider>().targetType!),
|
||||
onPressed: () => provider.analyzeImage(
|
||||
context.read<AnalysisProvider>().imagePath!,
|
||||
context.read<AnalysisProvider>().targetType!,
|
||||
),
|
||||
),
|
||||
if (_isCalibrating)
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _isCalibrating = false),
|
||||
child: const Text(
|
||||
'TERMINER',
|
||||
style: TextStyle(color: Colors.white),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -182,7 +231,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
controller: _scrollController,
|
||||
child: Column(
|
||||
children: [
|
||||
// Session info header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
@@ -199,57 +247,75 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
),
|
||||
),
|
||||
|
||||
// Target image with overlay or calibration
|
||||
AspectRatio(
|
||||
aspectRatio: provider.imageAspectRatio,
|
||||
child: _isCalibrating
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.file(
|
||||
File(provider.imagePath!),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
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, {
|
||||
List<double>? ringRadii,
|
||||
}) {
|
||||
provider.adjustTargetPosition(
|
||||
centerX,
|
||||
centerY,
|
||||
innerRadius,
|
||||
radius,
|
||||
ringCount: ringCount,
|
||||
ringRadii: ringRadii,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: _buildZoomableImageWithOverlay(context, provider),
|
||||
),
|
||||
|
||||
// Info cards or Calibration info
|
||||
if (!_isCalibrating)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
// Calibration button
|
||||
Card(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
child: ListTile(
|
||||
@@ -257,9 +323,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
Icons.tune,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
title: const Text('Calibrer la cible'),
|
||||
title: const Text('Ajuster la calibration'),
|
||||
subtitle: const Text(
|
||||
'Ajustez le centre et la taille',
|
||||
'Modifier le centre ou le rayon global',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
@@ -269,8 +335,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Score card
|
||||
ScoreCard(
|
||||
totalScore: provider.totalScore,
|
||||
shotCount: provider.shotCount,
|
||||
@@ -278,8 +342,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
targetType: provider.targetType!,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Grouping stats
|
||||
if (provider.groupingResult != null &&
|
||||
provider.shotCount > 1)
|
||||
GroupingStats(
|
||||
@@ -287,23 +349,17 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
targetCenterX: provider.targetCenterX,
|
||||
targetCenterY: provider.targetCenterY,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Action buttons
|
||||
_buildActionButtons(context, provider),
|
||||
|
||||
const SizedBox(height: 50),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
// Calibration info WITH directional arrows ABOVE the card
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
// Fleches de micro-ajustement AU DESSUS du bloc
|
||||
const Text(
|
||||
'Ajustement precis (pixel par pixel)',
|
||||
style: TextStyle(
|
||||
@@ -317,13 +373,16 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
return _calibrationKey.currentState?.buildDirectionalControls(context, size)
|
||||
?? const SizedBox.shrink();
|
||||
}
|
||||
return _calibrationKey.currentState
|
||||
?.buildDirectionalControls(
|
||||
context,
|
||||
size,
|
||||
) ??
|
||||
const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -340,15 +399,15 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
const SizedBox(height: 12),
|
||||
_buildInstructionItem(
|
||||
Icons.open_with,
|
||||
'Glissez le centre (croix bleue) pour positionner le centre de la cible',
|
||||
'Glissez le centre pour positionner le centre de la cible',
|
||||
),
|
||||
_buildInstructionItem(
|
||||
Icons.zoom_out_map,
|
||||
'Glissez le bord (cercle orange) pour ajuster la taille de la cible',
|
||||
'Pincez l\'ecran a 2 doigts ou utilisez la jauge pour la taille',
|
||||
),
|
||||
_buildInstructionItem(
|
||||
Icons.visibility,
|
||||
'Les zones de score sont affichees en transparence',
|
||||
'Appuyez sur TERMINER en haut a droite pour valider',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -398,11 +457,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
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'),
|
||||
),
|
||||
onPressed: () =>
|
||||
_showSaveSessionDialog(context, provider),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('TERMINER LA SESSION'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -418,86 +478,153 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildZoomableImageWithOverlay(
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
) {
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
) {
|
||||
return InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
minScale: 1.0,
|
||||
maxScale: 10.0,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.file(
|
||||
File(provider.imagePath!),
|
||||
key: _imageKey,
|
||||
fit: BoxFit.fill,
|
||||
panEnabled: _movingShotId == null,
|
||||
child: 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: GestureDetector(
|
||||
onDoubleTapDown: (TapDownDetails details) {
|
||||
final RenderBox? box =
|
||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
final localOffset = box.globalToLocal(details.globalPosition);
|
||||
final relX = localOffset.dx / box.size.width;
|
||||
final relY = localOffset.dy / box.size.height;
|
||||
|
||||
if (provider.shots.isEmpty) return;
|
||||
|
||||
Shot? closestShot;
|
||||
double minDistance = double.infinity;
|
||||
const double clickTolerance = 0.05;
|
||||
|
||||
for (final shot in provider.shots) {
|
||||
final dx = shot.x - relX;
|
||||
final dy = shot.y - relY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < minDistance && distance < clickTolerance) {
|
||||
minDistance = distance;
|
||||
closestShot = shot;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestShot != null) {
|
||||
_showShotDetails(context, provider, closestShot);
|
||||
}
|
||||
},
|
||||
onLongPressStart: (LongPressStartDetails details) {
|
||||
final RenderBox? box =
|
||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
final localOffset = box.globalToLocal(details.globalPosition);
|
||||
final relX = localOffset.dx / box.size.width;
|
||||
final relY = localOffset.dy / box.size.height;
|
||||
|
||||
if (provider.shots.isEmpty) return;
|
||||
|
||||
Shot? closestShot;
|
||||
double minDistance = double.infinity;
|
||||
const double dragTolerance = 0.06;
|
||||
|
||||
for (final shot in provider.shots) {
|
||||
final dx = shot.x - relX;
|
||||
final dy = shot.y - relY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < minDistance && distance < dragTolerance) {
|
||||
minDistance = distance;
|
||||
closestShot = shot;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestShot != null) {
|
||||
setState(() {
|
||||
_movingShotId = closestShot!.id;
|
||||
});
|
||||
}
|
||||
},
|
||||
onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) {
|
||||
if (_movingShotId == null) return;
|
||||
|
||||
final RenderBox? box =
|
||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
|
||||
final adjustedGlobalPosition =
|
||||
details.globalPosition + const Offset(-25, -35);
|
||||
final localOffset = box.globalToLocal(adjustedGlobalPosition);
|
||||
|
||||
final relX = (localOffset.dx / box.size.width).clamp(0.0, 1.0);
|
||||
final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0);
|
||||
|
||||
provider.updateShotPosition(_movingShotId!, relX, relY);
|
||||
},
|
||||
onLongPressEnd: (_) {
|
||||
if (_movingShotId != null) {
|
||||
setState(() {
|
||||
_movingShotId = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.file(
|
||||
File(provider.imagePath!),
|
||||
key: _imageKey,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
TargetOverlay(
|
||||
targetCenterX: provider.targetCenterX,
|
||||
targetCenterY: provider.targetCenterY,
|
||||
targetRadius: provider.targetRadius,
|
||||
targetType: provider.targetType!,
|
||||
shots: provider.shots,
|
||||
showRings: true,
|
||||
zoomScale: _currentZoomScale,
|
||||
onShotTapped: (shot) =>
|
||||
_showShotDetails(context, provider, shot),
|
||||
onAddShot: (relX, relY) => provider.addShot(relX, relY),
|
||||
),
|
||||
],
|
||||
),
|
||||
TargetOverlay(
|
||||
targetCenterX: provider.targetCenterX,
|
||||
targetCenterY: provider.targetCenterY,
|
||||
targetRadius: provider.targetRadius,
|
||||
targetType: provider.targetType!,
|
||||
shots: provider.shots,
|
||||
showRings: true,
|
||||
zoomScale: _currentZoomScale,
|
||||
onShotTapped: (shot) => _showShotDetails(context, provider, shot),
|
||||
onAddShot: (relX, relY) => provider.addShot(relX, relY),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
/*Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: null, // Desactive selon demande
|
||||
icon: const Icon(Icons.auto_awesome),
|
||||
label: const Text('AUTO DÉTECTER'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: null, // Desactive selon demande
|
||||
icon: const Icon(Icons.straighten),
|
||||
label: const Text('PAR RÉFÉRENCE'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
),*/
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
return const Column(children: [Row(children: [])]);
|
||||
}
|
||||
|
||||
void _showShotDetails(
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
Shot shot,
|
||||
) {
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
Shot shot,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
@@ -511,7 +638,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
),
|
||||
Text(
|
||||
'ID: ${shot.id}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
@@ -520,15 +649,23 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
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)),
|
||||
))
|
||||
.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); // Ferme pour valider
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -543,7 +680,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
label: const Text('SUPPRIMER', style: TextStyle(color: Colors.red)),
|
||||
label: const Text(
|
||||
'SUPPRIMER',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -555,8 +695,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
}
|
||||
|
||||
void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) {
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -574,19 +712,19 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Close dialog
|
||||
|
||||
final path = provider.imagePath!;
|
||||
Navigator.pop(context);
|
||||
final type = provider.targetType!;
|
||||
|
||||
|
||||
// Retour au centrage : on repart de l'ORIGINAL (pas de re-crop),
|
||||
// en conservant uniquement la rotation.
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CropScreen(
|
||||
imagePath: path,
|
||||
imagePath: widget.originalImagePath,
|
||||
originalImagePath: widget.originalImagePath,
|
||||
targetType: type,
|
||||
initialScale: widget.cropScale,
|
||||
initialOffset: widget.cropOffset,
|
||||
initialRotation: provider.cropRotation,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -602,24 +740,28 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
weaponName: sessionProvider.currentWeapon,
|
||||
weaponId: sessionProvider.currentWeaponId,
|
||||
distance: sessionProvider.distance,
|
||||
date: sessionProvider.sessionDate,
|
||||
);
|
||||
|
||||
// Mettre à jour le provider de session pour incrémenter le compteur
|
||||
|
||||
sessionProvider.addAnalysis(analysis);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context); // Close dialog
|
||||
// Go back to capture screen for a new target in the same session context
|
||||
Navigator.pop(context);
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const CaptureScreen()),
|
||||
(route) => route.isFirst,
|
||||
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),
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -635,20 +777,21 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
weaponName: sessionProvider.currentWeapon,
|
||||
weaponId: sessionProvider.currentWeaponId,
|
||||
distance: sessionProvider.distance,
|
||||
date: sessionProvider.sessionDate,
|
||||
);
|
||||
|
||||
|
||||
if (context.mounted) {
|
||||
// Important: Marquer la session comme terminée dans le provider global
|
||||
sessionProvider.endSession();
|
||||
|
||||
Navigator.pop(context); // Ferme le dialogue
|
||||
// Retourne à l'accueil (premier écran)
|
||||
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),
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -659,4 +802,4 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,13 @@ class TargetCalibration extends StatefulWidget {
|
||||
final TargetType targetType;
|
||||
final List<double>? initialRingRadii;
|
||||
final Function(
|
||||
double centerX,
|
||||
double centerY,
|
||||
double innerRadius,
|
||||
double radius,
|
||||
int ringCount, {
|
||||
List<double>? ringRadii,
|
||||
})
|
||||
double centerX,
|
||||
double centerY,
|
||||
double innerRadius,
|
||||
double radius,
|
||||
int ringCount, {
|
||||
List<double>? ringRadii,
|
||||
})
|
||||
onCalibrationChanged;
|
||||
|
||||
const TargetCalibration({
|
||||
@@ -51,10 +51,19 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
late int _ringCount;
|
||||
late List<double> _ringRadii;
|
||||
|
||||
// Variable dédiée pour piloter la jauge orange sans conflit
|
||||
late double _currentEspacementRatio;
|
||||
|
||||
bool _isDraggingCenter = false;
|
||||
bool _isDraggingRadius = false;
|
||||
bool _isDraggingInnerRadius = false;
|
||||
|
||||
bool _showEspacement = false;
|
||||
double _baseRadiusBeforeScale = 1.0;
|
||||
|
||||
// SAUVEGARDE CRITIQUE : Pour mémoriser la structure d'origine de l'IA
|
||||
List<double>? _originalRingRadii;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -63,19 +72,27 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
_radius = widget.initialRadius;
|
||||
_innerRadius = widget.initialInnerRadius;
|
||||
_ringCount = widget.initialRingCount;
|
||||
|
||||
// On mémorise la configuration d'usine de l'image
|
||||
if (widget.initialRingRadii != null) {
|
||||
_originalRingRadii = List.from(widget.initialRingRadii!);
|
||||
}
|
||||
|
||||
// Initialisation du ratio par défaut
|
||||
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||
_initRingRadii();
|
||||
}
|
||||
|
||||
void _initRingRadii({bool forceRecalculate = false}) {
|
||||
if (!forceRecalculate && widget.initialRingRadii != null &&
|
||||
widget.initialRingRadii!.length == _ringCount) {
|
||||
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
|
||||
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
|
||||
_ringRadii = List.from(_originalRingRadii!);
|
||||
} else if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
|
||||
_ringRadii = List.from(widget.initialRingRadii!);
|
||||
} else {
|
||||
// Initialize with default proportional radii interpolated between inner and outer
|
||||
_ringRadii = List.generate(_ringCount, (i) {
|
||||
if (_ringCount <= 1) return 1.0;
|
||||
final ratio = _innerRadius / _radius;
|
||||
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
|
||||
return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,12 +102,10 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
bool shouldReinit = false;
|
||||
|
||||
if (widget.initialCenterX != oldWidget.initialCenterX &&
|
||||
!_isDraggingCenter) {
|
||||
if (widget.initialCenterX != oldWidget.initialCenterX && !_isDraggingCenter) {
|
||||
_centerX = widget.initialCenterX;
|
||||
}
|
||||
if (widget.initialCenterY != oldWidget.initialCenterY &&
|
||||
!_isDraggingCenter) {
|
||||
if (widget.initialCenterY != oldWidget.initialCenterY && !_isDraggingCenter) {
|
||||
_centerY = widget.initialCenterY;
|
||||
}
|
||||
if (widget.initialRingCount != oldWidget.initialRingCount) {
|
||||
@@ -101,12 +116,13 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
_radius = widget.initialRadius;
|
||||
shouldReinit = true;
|
||||
}
|
||||
if (widget.initialInnerRadius != oldWidget.initialInnerRadius &&
|
||||
!_isDraggingInnerRadius) {
|
||||
if (widget.initialInnerRadius != oldWidget.initialInnerRadius && !_isDraggingInnerRadius) {
|
||||
_innerRadius = widget.initialInnerRadius;
|
||||
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||
shouldReinit = true;
|
||||
}
|
||||
if (widget.initialRingRadii != oldWidget.initialRingRadii) {
|
||||
if (widget.initialRingRadii != oldWidget.initialRingRadii && widget.initialRingRadii != null) {
|
||||
_originalRingRadii = List.from(widget.initialRingRadii!);
|
||||
shouldReinit = true;
|
||||
}
|
||||
|
||||
@@ -124,9 +140,20 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onPanStart: (details) => _onPanStart(details, size),
|
||||
onPanUpdate: (details) => _onPanUpdate(details, size),
|
||||
onPanEnd: (_) => _onPanEnd(),
|
||||
onScaleStart: (details) {
|
||||
_baseRadiusBeforeScale = _radius;
|
||||
final tapX = details.localFocalPoint.dx / size.width;
|
||||
final tapY = details.localFocalPoint.dy / size.height;
|
||||
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
||||
|
||||
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
||||
setState(() {
|
||||
_isDraggingCenter = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
onScaleUpdate: (details) => _onScaleUpdate(details, size),
|
||||
onScaleEnd: (_) => _onScaleEnd(),
|
||||
child: CustomPaint(
|
||||
size: size,
|
||||
painter: _CalibrationPainter(
|
||||
@@ -138,13 +165,12 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
ringRadii: _ringRadii,
|
||||
targetType: widget.targetType,
|
||||
isDraggingCenter: _isDraggingCenter,
|
||||
isDraggingRadius: false, // Plus de drag direct du rayon
|
||||
isDraggingRadius: false,
|
||||
isDraggingInnerRadius: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Slider pour le rayon (agrandissement)
|
||||
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 40,
|
||||
@@ -152,6 +178,42 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Options d\'espacement avancées',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500),
|
||||
),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: Switch(
|
||||
value: _showEspacement,
|
||||
activeThumbColor: const Color(0xFF00FF00),
|
||||
onChanged: (bool value) {
|
||||
setState(() {
|
||||
_showEspacement = value;
|
||||
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine !
|
||||
if (!value) {
|
||||
_initRingRadii(forceRecalculate: false);
|
||||
}
|
||||
});
|
||||
_notifyChange();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Slider pour la taille (toujours visible)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -164,15 +226,17 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _radius.clamp(0.05, 3.0),
|
||||
min: 0.05,
|
||||
max: 3.0,
|
||||
// SÉCURITÉ : Ouverture mesurée des bornes pour plus de liberté sans débordement incontrôlé
|
||||
value: _radius.clamp(0.3, 0.95),
|
||||
min: 0.3,
|
||||
max: 0.95,
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_radius = value;
|
||||
// On force le recalcul car on change la taille
|
||||
_initRingRadii(forceRecalculate: true);
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
// CORRECTION : Si le mode avancé n'est pas coché, on applique la taille pure sans détruire le ratio d'origine
|
||||
_initRingRadii(forceRecalculate: _showEspacement);
|
||||
});
|
||||
_notifyChange();
|
||||
},
|
||||
@@ -182,41 +246,74 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const Icon(Icons.compress, color: Colors.white, size: 16),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: (_innerRadius / _radius).clamp(0.01, 0.9),
|
||||
min: 0.01,
|
||||
max: 0.9,
|
||||
activeColor: Colors.orange,
|
||||
onChanged: (value) {
|
||||
|
||||
// Affichage conditionnel du slider d'espacement orange
|
||||
if (_showEspacement) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const Icon(Icons.compress, color: Colors.white, size: 16),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _currentEspacementRatio,
|
||||
min: 0.01,
|
||||
// SÉCURITÉ : Écartement max bridé à 0.70 pour confiner le dernier cercle
|
||||
max: 0.70,
|
||||
activeColor: Colors.orange,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_currentEspacementRatio = value;
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
_initRingRadii(forceRecalculate: true);
|
||||
});
|
||||
_notifyChange();
|
||||
},
|
||||
),
|
||||
),
|
||||
const Icon(Icons.expand, color: Colors.white, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
// CORRECTION DU BOUTON RESET : Restaure désormais le vrai profil d'usine de l'IA
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
// On ajuste l'innerRadius comme un ratio du radius global
|
||||
_innerRadius = _radius * value;
|
||||
_initRingRadii(forceRecalculate: true);
|
||||
if (_originalRingRadii != null) {
|
||||
_initRingRadii(forceRecalculate: false);
|
||||
if (_ringRadii.isNotEmpty) {
|
||||
_currentEspacementRatio = _ringRadii.first;
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
}
|
||||
} else {
|
||||
_currentEspacementRatio = 0.1;
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
_initRingRadii(forceRecalculate: true);
|
||||
}
|
||||
});
|
||||
_notifyChange();
|
||||
},
|
||||
tooltip: 'Réinitialiser l\'espacement',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.expand, color: Colors.white, size: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -234,28 +331,16 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDirectionButton(
|
||||
Icons.keyboard_arrow_up,
|
||||
() => _moveCenterByPixels(0, -1, size),
|
||||
),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDirectionButton(
|
||||
Icons.keyboard_arrow_left,
|
||||
() => _moveCenterByPixels(-1, 0, size),
|
||||
),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
|
||||
const SizedBox(width: 40),
|
||||
_buildDirectionButton(
|
||||
Icons.keyboard_arrow_right,
|
||||
() => _moveCenterByPixels(1, 0, size),
|
||||
),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
|
||||
],
|
||||
),
|
||||
_buildDirectionButton(
|
||||
Icons.keyboard_arrow_down,
|
||||
() => _moveCenterByPixels(0, 1, size),
|
||||
),
|
||||
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -263,10 +348,7 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
|
||||
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||
margin: const EdgeInsets.all(2),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: Colors.white, size: 28),
|
||||
@@ -296,36 +378,23 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
_notifyChange();
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails details, Size size) {
|
||||
final tapX = details.localPosition.dx / size.width;
|
||||
final tapY = details.localPosition.dy / size.height;
|
||||
|
||||
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
||||
|
||||
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
||||
setState(() {
|
||||
_isDraggingCenter = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details, Size size) {
|
||||
final deltaX = details.delta.dx / size.width;
|
||||
final deltaY = details.delta.dy / size.height;
|
||||
final minDim = math.min(size.width, size.height);
|
||||
|
||||
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
|
||||
setState(() {
|
||||
if (_isDraggingCenter) {
|
||||
// Move center
|
||||
if (details.pointerCount == 2) {
|
||||
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95);
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
_initRingRadii(forceRecalculate: _showEspacement);
|
||||
} else if (_isDraggingCenter) {
|
||||
final deltaX = details.focalPointDelta.dx / size.width;
|
||||
final deltaY = details.focalPointDelta.dy / size.height;
|
||||
_centerX = _centerX + deltaX;
|
||||
_centerY = _centerY + deltaY;
|
||||
}
|
||||
});
|
||||
|
||||
_notifyChange();
|
||||
}
|
||||
|
||||
void _onPanEnd() {
|
||||
void _onScaleEnd() {
|
||||
setState(() {
|
||||
_isDraggingCenter = false;
|
||||
_isDraggingRadius = false;
|
||||
@@ -370,7 +439,6 @@ class _CalibrationPainter extends CustomPainter {
|
||||
final centerPx = Offset(centerX * size.width, centerY * size.height);
|
||||
final minDim = size.width < size.height ? size.width : size.height;
|
||||
final baseRadiusPx = radius * minDim;
|
||||
final innerRadiusPx = innerRadius * minDim;
|
||||
|
||||
if (targetType == TargetType.concentric) {
|
||||
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
|
||||
@@ -378,37 +446,17 @@ class _CalibrationPainter extends CustomPainter {
|
||||
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
|
||||
}
|
||||
|
||||
// Fullscreen crosshairs when dragging center
|
||||
if (isDraggingCenter) {
|
||||
final crosshairLinePaint = Paint()
|
||||
..color = Colors.red.withValues(alpha: 0.5)
|
||||
..strokeWidth = 1;
|
||||
canvas.drawLine(
|
||||
Offset(0, centerPx.dy),
|
||||
Offset(size.width, centerPx.dy),
|
||||
crosshairLinePaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(centerPx.dx, 0),
|
||||
Offset(centerPx.dx, size.height),
|
||||
crosshairLinePaint,
|
||||
);
|
||||
final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
|
||||
canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
|
||||
canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
|
||||
}
|
||||
|
||||
// Draw center handle
|
||||
_drawCenterHandle(canvas, centerPx);
|
||||
|
||||
// Draw instructions
|
||||
_drawInstructions(canvas, size);
|
||||
}
|
||||
|
||||
void _drawConcentricZones(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
Offset center,
|
||||
double baseRadius,
|
||||
) {
|
||||
// Generate colors for zones
|
||||
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
|
||||
List<Color> zoneColors = [];
|
||||
for (int i = 0; i < ringCount; i++) {
|
||||
final ratio = i / ringCount;
|
||||
@@ -431,11 +479,8 @@ class _CalibrationPainter extends CustomPainter {
|
||||
..strokeWidth = 1
|
||||
..color = Colors.red.withValues(alpha: 0.8);
|
||||
|
||||
// Draw from outside to inside
|
||||
for (int i = ringCount - 1; i >= 0; i--) {
|
||||
final ringRadius = ringRadii.length > i
|
||||
? ringRadii[i]
|
||||
: (i + 1) / ringCount;
|
||||
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
|
||||
final zoneRadius = baseRadius * ringRadius;
|
||||
|
||||
zonePaint.color = zoneColors[i];
|
||||
@@ -443,22 +488,14 @@ class _CalibrationPainter extends CustomPainter {
|
||||
canvas.drawCircle(center, zoneRadius, strokePaint);
|
||||
}
|
||||
|
||||
// Draw zone labels (only if within visible area)
|
||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||
|
||||
for (int i = 0; i < ringCount; i++) {
|
||||
final ringRadius = ringRadii.length > i
|
||||
? ringRadii[i]
|
||||
: (i + 1) / ringCount;
|
||||
final prevRingRadius = i > 0
|
||||
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
|
||||
: 0.0;
|
||||
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
|
||||
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
|
||||
final textSpanRatio = i > 0 ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) : 0.0;
|
||||
final zoneRadius = baseRadius * (ringRadius + textSpanRatio) / 2;
|
||||
|
||||
// Score: center = 10, decrement by 1 for each ring
|
||||
final score = 10 - i;
|
||||
|
||||
// Only draw label if it's within the visible area
|
||||
final labelX = center.dx + zoneRadius;
|
||||
if (labelX < 0 || labelX > size.width) continue;
|
||||
|
||||
@@ -473,102 +510,44 @@ class _CalibrationPainter extends CustomPainter {
|
||||
);
|
||||
textPainter.layout();
|
||||
|
||||
// Draw label on the right side of each zone
|
||||
final labelY = center.dy - textPainter.height / 2;
|
||||
if (labelY >= 0 && labelY <= size.height) {
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(labelX - textPainter.width / 2, labelY),
|
||||
);
|
||||
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawSilhouetteZones(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
Offset center,
|
||||
double radius,
|
||||
) {
|
||||
// Simplified silhouette zones
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
|
||||
// Draw silhouette outline (simplified as rectangle for now)
|
||||
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
|
||||
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
|
||||
final silhouetteWidth = radius * 0.8;
|
||||
final silhouetteHeight = radius * 2;
|
||||
|
||||
paint.color = Colors.green.withValues(alpha: 0.5);
|
||||
canvas.drawRect(
|
||||
Rect.fromCenter(
|
||||
center: center,
|
||||
width: silhouetteWidth,
|
||||
height: silhouetteHeight,
|
||||
),
|
||||
paint,
|
||||
);
|
||||
canvas.drawRect(Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), paint);
|
||||
}
|
||||
|
||||
void _drawCenterHandle(Canvas canvas, Offset center) {
|
||||
// Outer circle
|
||||
final outerPaint = Paint()
|
||||
..color = isDraggingCenter ? Colors.redAccent : Colors.red
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3;
|
||||
final outerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.stroke..strokeWidth = 3;
|
||||
canvas.drawCircle(center, 15, outerPaint);
|
||||
|
||||
// Inner dot
|
||||
final innerPaint = Paint()
|
||||
..color = isDraggingCenter ? Colors.redAccent : Colors.red
|
||||
..style = PaintingStyle.fill;
|
||||
final innerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(center, 5, innerPaint);
|
||||
|
||||
// Crosshair
|
||||
final crossPaint = Paint()
|
||||
..color = isDraggingCenter ? Colors.redAccent : Colors.red
|
||||
..strokeWidth = 2;
|
||||
canvas.drawLine(
|
||||
Offset(center.dx - 20, center.dy),
|
||||
Offset(center.dx - 8, center.dy),
|
||||
crossPaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(center.dx + 8, center.dy),
|
||||
Offset(center.dx + 20, center.dy),
|
||||
crossPaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(center.dx, center.dy - 20),
|
||||
Offset(center.dx, center.dy - 8),
|
||||
crossPaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(center.dx, center.dy + 8),
|
||||
Offset(center.dx, center.dy + 20),
|
||||
crossPaint,
|
||||
);
|
||||
final crossPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..strokeWidth = 2;
|
||||
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
|
||||
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
|
||||
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
|
||||
canvas.drawLine(Offset(center.dx, center.dy + 4), Offset(center.dx, center.dy + 20), crossPaint);
|
||||
}
|
||||
|
||||
void _drawInstructions(Canvas canvas, Size size) {
|
||||
const instruction = 'Deplacez le centre ou ajustez le rayon';
|
||||
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: instruction,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 12,
|
||||
backgroundColor: Colors.black54,
|
||||
),
|
||||
),
|
||||
text: TextSpan(text: instruction, style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 12, backgroundColor: Colors.black54)),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset((size.width - textPainter.width) / 2, size.height - 30),
|
||||
);
|
||||
textPainter.paint(canvas, Offset((size.width - textPainter.width) / 2, size.height - 30));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -584,4 +563,4 @@ class _CalibrationPainter extends CustomPainter {
|
||||
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
|
||||
ringRadii != oldDelegate.ringRadii;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -12,6 +11,8 @@ import '../../data/models/target_type.dart';
|
||||
import '../crop/crop_screen.dart';
|
||||
import '../session/session_provider.dart';
|
||||
import 'widgets/image_source_button.dart';
|
||||
import '../../services/image_crop_service.dart';
|
||||
import '../../services/document_scanner_service.dart'; // Scanner ML Kit : redresse la cible de face
|
||||
|
||||
class CaptureScreen extends StatefulWidget {
|
||||
const CaptureScreen({super.key});
|
||||
@@ -22,29 +23,122 @@ class CaptureScreen extends StatefulWidget {
|
||||
|
||||
class _CaptureScreenState extends State<CaptureScreen> {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
final ImageCropService _cropService = ImageCropService();
|
||||
final TargetType _selectedType = TargetType.concentric;
|
||||
|
||||
// Scanner de documents Google ML Kit (redresse la cible de face automatiquement)
|
||||
final DocumentScannerService _docScanner = DocumentScannerService();
|
||||
|
||||
String? _selectedImagePath;
|
||||
bool _isLoading = false;
|
||||
|
||||
/// Gère la demande de permission et la sélection d'image depuis la galerie
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Build principal
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final title = sessionProvider.isSessionActive
|
||||
? 'Cible ${sessionProvider.targetCount + 1}'
|
||||
: 'Source';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: AppConstants.largePadding),
|
||||
_buildSectionTitle('Source de l\'Image'),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ImageSourceButton(
|
||||
icon: Icons.document_scanner,
|
||||
label: 'Scanner',
|
||||
onPressed: _isLoading ? null : _scanWithDocumentScanner,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ImageSourceButton(
|
||||
icon: Icons.photo_library,
|
||||
label: 'Galerie',
|
||||
onPressed: _isLoading ? null : _handleGallerySelection,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConstants.largePadding),
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildGuide(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Déclic → lance le scanner de documents Google ML Kit qui redresse
|
||||
// automatiquement la cible (feuille rectangulaire) de face.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Future<void> _scanWithDocumentScanner() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final result = await _docScanner.scanTarget();
|
||||
|
||||
if (!result.success) {
|
||||
// L'utilisateur a annulé le scan : on ne fait rien de plus.
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedImagePath = result.imagePath;
|
||||
});
|
||||
|
||||
_analyzeImage();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur scanner document: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Le scan a échoué. Réessayez.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Galerie (inchangée)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Future<void> _handleGallerySelection() async {
|
||||
PermissionStatus status;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
|
||||
if (androidInfo.version.sdkInt >= 33) {
|
||||
status = await Permission.photos.request();
|
||||
} else {
|
||||
status = await Permission.storage.request();
|
||||
}
|
||||
status = androidInfo.version.sdkInt >= 33
|
||||
? await Permission.photos.request()
|
||||
: await Permission.storage.request();
|
||||
} else {
|
||||
status = await Permission.photos.request();
|
||||
}
|
||||
|
||||
if (status.isGranted) {
|
||||
_captureImage(ImageSource.gallery);
|
||||
_captureImageFromGallery();
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showSettingsDialog();
|
||||
} else {
|
||||
@@ -80,73 +174,55 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final title = sessionProvider.isSessionActive
|
||||
? 'Cible ${sessionProvider.targetCount + 1}'
|
||||
: 'Source';
|
||||
Future<void> _captureImageFromGallery() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_selectedImagePath = image.path;
|
||||
});
|
||||
_analyzeImage();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur galerie: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: AppConstants.largePadding),
|
||||
void _analyzeImage() {
|
||||
if (_selectedImagePath == null) return;
|
||||
|
||||
_buildSectionTitle('Source de l\'Image'),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ImageSourceButton(
|
||||
icon: Icons.camera_alt,
|
||||
label: 'Scanner',
|
||||
onPressed: _isLoading ? null : _scanDocument,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ImageSourceButton(
|
||||
icon: Icons.photo_library,
|
||||
label: 'Galerie',
|
||||
onPressed: _isLoading ? null : _handleGallerySelection,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConstants.largePadding),
|
||||
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildGuide(),
|
||||
],
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CropScreen(
|
||||
imagePath: _selectedImagePath!,
|
||||
targetType: _selectedType,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers UI
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Widget _buildGuide() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -157,9 +233,10 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Conseils pour une bonne analyse',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildGuideItem(
|
||||
@@ -191,59 +268,4 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scanDocument() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final options = DocumentScannerOptions(
|
||||
documentFormat: DocumentFormat.jpeg,
|
||||
mode: ScannerMode.base,
|
||||
pageLimit: 1,
|
||||
isGalleryImport: false,
|
||||
);
|
||||
final scanner = DocumentScanner(options: options);
|
||||
final documents = await scanner.scanDocument();
|
||||
if (documents.images.isNotEmpty) {
|
||||
_selectedImagePath = documents.images.first;
|
||||
_analyzeImage();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur scan: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _captureImage(ImageSource source) async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(
|
||||
source: source,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (image != null) {
|
||||
_selectedImagePath = image.path;
|
||||
_analyzeImage();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur capture: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _analyzeImage() {
|
||||
if (_selectedImagePath == null) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CropScreen(
|
||||
imagePath: _selectedImagePath!,
|
||||
targetType: _selectedType,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,28 @@ import '../analysis/analysis_screen.dart';
|
||||
import 'widgets/crop_overlay.dart';
|
||||
|
||||
class CropScreen extends StatefulWidget {
|
||||
/// Image affichée/recadrée dans cet écran. Au tout premier passage, c'est
|
||||
/// aussi l'image originale. Lors des allers-retours, on recharge toujours
|
||||
/// l'ORIGINAL ici (jamais une image déjà recadrée) pour éviter le zoom
|
||||
/// cumulatif.
|
||||
final String imagePath;
|
||||
|
||||
/// Chemin de l'image ORIGINALE (jamais recadrée). Si null, [imagePath] est
|
||||
/// considéré comme l'original (premier passage depuis la caméra/galerie).
|
||||
final String? originalImagePath;
|
||||
|
||||
final TargetType targetType;
|
||||
final double? initialScale;
|
||||
final Offset? initialOffset;
|
||||
|
||||
/// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement
|
||||
/// PAS restauré : on repart toujours de l'image entière.
|
||||
final double? initialRotation;
|
||||
|
||||
const CropScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
this.originalImagePath,
|
||||
required this.targetType,
|
||||
this.initialScale,
|
||||
this.initialOffset,
|
||||
this.initialRotation,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -28,7 +39,7 @@ class CropScreen extends StatefulWidget {
|
||||
|
||||
class _CropScreenState extends State<CropScreen> {
|
||||
final ImageCropService _cropService = ImageCropService();
|
||||
|
||||
|
||||
// États de transformation
|
||||
double _scale = 1.0;
|
||||
double _baseScale = 1.0;
|
||||
@@ -36,20 +47,29 @@ class _CropScreenState extends State<CropScreen> {
|
||||
Offset _startOffset = Offset.zero;
|
||||
Offset _startFocalPoint = Offset.zero;
|
||||
|
||||
// PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés
|
||||
double _rotation = 0.0;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _imageLoaded = false;
|
||||
Size? _imageSize;
|
||||
late Size _viewportSize;
|
||||
late double _cropSize;
|
||||
|
||||
/// L'image effectivement travaillée par cet écran : toujours l'originale.
|
||||
String get _workingImagePath =>
|
||||
widget.originalImagePath ?? widget.imagePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// On restaure uniquement la rotation (pas le zoom).
|
||||
_rotation = widget.initialRotation ?? 0.0;
|
||||
_loadImageInfo();
|
||||
}
|
||||
|
||||
Future<void> _loadImageInfo() async {
|
||||
final file = File(widget.imagePath);
|
||||
final file = File(_workingImagePath);
|
||||
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -78,85 +98,155 @@ class _CropScreenState extends State<CropScreen> {
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||
tooltip: 'Réinitialiser l\'orientation',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_rotation = 0.0;
|
||||
_scale = 1.0;
|
||||
_offset = Offset.zero;
|
||||
_initializeImagePosition();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||
: Column(
|
||||
children: [
|
||||
// Zone interactive de crop
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// TEXTE D'AIDE AJUSTÉ
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Alignez et pivotez la cible sur la croix',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Rotation de l\'image',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'${_rotation.toStringAsFixed(1)}°',
|
||||
style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.rotate_left, color: Colors.white38, size: 20),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _rotation,
|
||||
min: -15.0,
|
||||
max: 15.0,
|
||||
divisions: 300,
|
||||
label: '${_rotation.toStringAsFixed(1)}°',
|
||||
activeColor: const Color(0xFF1A73E8),
|
||||
inactiveColor: Colors.white12,
|
||||
onChanged: (double value) {
|
||||
setState(() {
|
||||
_rotation = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const Icon(Icons.rotate_right, color: Colors.white38, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_rotation = 0.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Boutons du bas
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
|
||||
child: Row(
|
||||
children: [
|
||||
// Zone interactive de crop
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Retour', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton d'aide / Modifier
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.center_focus_strong, color: Colors.redAccent, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Alignez le centre de la cible sur la croix',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Zoomez pour plus de précision',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Boutons du bas
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Retour', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _onCropConfirm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A73E8),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Analyser', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _onCropConfirm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A73E8),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,9 +254,23 @@ class _CropScreenState extends State<CropScreen> {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
|
||||
// Le carré de crop occupe presque tout l'espace disponible
|
||||
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95;
|
||||
|
||||
// FIX : On calcule d'abord la taille de la photo affichée avant de définir la taille du cadre vert !
|
||||
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
displayHeight = _viewportSize.width / imageAspect;
|
||||
} else {
|
||||
displayHeight = _viewportSize.height;
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
// Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum),
|
||||
// au lieu des 85% précédents qui rognaient inutilement les bords.
|
||||
_cropSize = math.min(displayWidth, displayHeight);
|
||||
|
||||
if (_scale == 1.0 && _offset == Offset.zero) {
|
||||
_initializeImagePosition();
|
||||
@@ -177,15 +281,17 @@ class _CropScreenState extends State<CropScreen> {
|
||||
onScaleUpdate: _onScaleUpdate,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Transform(
|
||||
transform: Matrix4.identity()
|
||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
||||
..scale(_scale, _scale),
|
||||
..scale(_scale, _scale)
|
||||
..rotateZ(_rotation * (math.pi / 180)),
|
||||
alignment: Alignment.center,
|
||||
child: Image.file(
|
||||
File(widget.imagePath),
|
||||
File(_workingImagePath),
|
||||
fit: BoxFit.contain,
|
||||
width: _viewportSize.width,
|
||||
height: _viewportSize.height,
|
||||
@@ -193,6 +299,55 @@ class _CropScreenState extends State<CropScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Les lignes vertes de visée prolongées
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 1.5,
|
||||
color: const Color(0xFF00FF00).withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 1.5,
|
||||
height: double.infinity,
|
||||
color: const Color(0xFF00FF00).withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
const Color(0xFF101214),
|
||||
BlendMode.srcOut,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: _cropSize,
|
||||
height: _cropSize,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 4. Ton overlay avec les coins blancs
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CropOverlay(cropSize: _cropSize, showGrid: false),
|
||||
@@ -207,10 +362,11 @@ class _CropScreenState extends State<CropScreen> {
|
||||
|
||||
void _initializeImagePosition() {
|
||||
if (_imageSize == null) return;
|
||||
|
||||
|
||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
// 1. Calcul strict de la taille de l'image affichée en BoxFit.contain
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
@@ -220,14 +376,10 @@ class _CropScreenState extends State<CropScreen> {
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
final minDisplayDim = math.min(displayWidth, displayHeight);
|
||||
|
||||
_scale = widget.initialScale ?? (_cropSize / minDisplayDim);
|
||||
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
|
||||
|
||||
if (widget.initialOffset != null) {
|
||||
_offset = widget.initialOffset!;
|
||||
}
|
||||
// On repart TOUJOURS de l'image entière, centrée, sans zoom :
|
||||
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours.
|
||||
_scale = 1.0;
|
||||
_offset = Offset.zero;
|
||||
}
|
||||
|
||||
void _onScaleStart(ScaleStartDetails details) {
|
||||
@@ -247,21 +399,44 @@ class _CropScreenState extends State<CropScreen> {
|
||||
Future<void> _onCropConfirm() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer
|
||||
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants
|
||||
final savedScale = _scale;
|
||||
final savedOffset = _offset;
|
||||
_scale = 1.0;
|
||||
_offset = Offset.zero;
|
||||
|
||||
final cropRect = _calculateCropRect();
|
||||
// On calcule le centre relatif basé sur le centrage utilisateur
|
||||
final targetCenterX = cropRect.x + cropRect.width / 2;
|
||||
final targetCenterY = cropRect.y + cropRect.height / 2;
|
||||
|
||||
// On restaure pour l'affichage (au cas où on revient en arrière)
|
||||
_scale = savedScale;
|
||||
_offset = savedOffset;
|
||||
|
||||
// Le crop est TOUJOURS calculé sur l'image originale, jamais sur un
|
||||
// résultat déjà recadré.
|
||||
final croppedImagePath = await _cropService.cropToSquare(
|
||||
_workingImagePath,
|
||||
cropRect,
|
||||
rotationDegrees: _rotation,
|
||||
);
|
||||
|
||||
const targetCenterX = 0.5;
|
||||
const targetCenterY = 0.5;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AnalysisScreen(
|
||||
imagePath: widget.imagePath,
|
||||
imagePath: croppedImagePath,
|
||||
// On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir
|
||||
// revenir au centrage sans jamais re-recadrer le résultat.
|
||||
originalImagePath: _workingImagePath,
|
||||
cropRotation: _rotation,
|
||||
targetType: widget.targetType,
|
||||
targetCenter: Offset(targetCenterX, targetCenterY),
|
||||
cropScale: _scale,
|
||||
cropOffset: _offset,
|
||||
initialCenterX: targetCenterX,
|
||||
initialCenterY: targetCenterY,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -269,7 +444,7 @@ class _CropScreenState extends State<CropScreen> {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
|
||||
SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -277,7 +452,7 @@ class _CropScreenState extends State<CropScreen> {
|
||||
|
||||
CropRect _calculateCropRect() {
|
||||
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
||||
|
||||
|
||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
@@ -292,13 +467,13 @@ class _CropScreenState extends State<CropScreen> {
|
||||
|
||||
final scaledWidth = displayWidth * _scale;
|
||||
final scaledHeight = displayHeight * _scale;
|
||||
|
||||
|
||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
||||
|
||||
|
||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
||||
final imageTop = imageCenterY - scaledHeight / 2;
|
||||
|
||||
|
||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
||||
|
||||
@@ -308,10 +483,10 @@ class _CropScreenState extends State<CropScreen> {
|
||||
final relCropHeight = _cropSize / scaledHeight;
|
||||
|
||||
return CropRect(
|
||||
x: relCropLeft,
|
||||
y: relCropTop,
|
||||
width: relCropWidth,
|
||||
height: relCropHeight,
|
||||
x: relCropLeft.clamp(0.0, 1.0),
|
||||
y: relCropTop.clamp(0.0, 1.0),
|
||||
width: relCropWidth.clamp(0.0, 1.0),
|
||||
height: relCropHeight.clamp(0.0, 1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ class _CropOverlayPainter extends CustomPainter {
|
||||
_drawGrid(canvas, cropRect);
|
||||
}
|
||||
|
||||
// Dessiner le point central (croix)
|
||||
// Dessiner le point central (croix interne vert fluo)
|
||||
_drawCenterPoint(canvas, cropRect);
|
||||
}
|
||||
|
||||
@@ -165,39 +165,44 @@ class _CropOverlayPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
void _drawCenterPoint(Canvas canvas, Rect rect) {
|
||||
// Peinture de la croix interne (Vert Fluo)
|
||||
final centerPaint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.8)
|
||||
..color = const Color(0xFF00FF00)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
const size = 10.0;
|
||||
// Cercle extérieur (Vert Fluo fin)
|
||||
final circlePaint = Paint()
|
||||
..color = const Color(0xFF00FF00).withValues(alpha: 0.9)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2;
|
||||
|
||||
const circleRadius = 10.0; // Augmenté légèrement à 10 pour que la croix respire
|
||||
final centerX = rect.center.dx;
|
||||
final centerY = rect.center.dy;
|
||||
|
||||
// Ligne horizontale
|
||||
// 1. Dessiner le cercle de contour
|
||||
canvas.drawCircle(rect.center, circleRadius, circlePaint);
|
||||
|
||||
// 2. Dessiner la croix UNIQUEMENT à l'intérieur du cercle
|
||||
|
||||
// Ligne horizontale (de gauche à droite, reste à l'intérieur)
|
||||
canvas.drawLine(
|
||||
Offset(centerX - size, centerY),
|
||||
Offset(centerX + size, centerY),
|
||||
Offset(centerX - circleRadius, centerY),
|
||||
Offset(centerX + circleRadius, centerY),
|
||||
centerPaint,
|
||||
);
|
||||
|
||||
// Ligne verticale
|
||||
// Ligne verticale (de haut en bas, reste à l'intérieur)
|
||||
canvas.drawLine(
|
||||
Offset(centerX, centerY - size),
|
||||
Offset(centerX, centerY + size),
|
||||
Offset(centerX, centerY - circleRadius),
|
||||
Offset(centerX, centerY + circleRadius),
|
||||
centerPaint,
|
||||
);
|
||||
|
||||
// Petit cercle central pour précision (optionnel, mais aide à viser)
|
||||
canvas.drawCircle(
|
||||
rect.center,
|
||||
2,
|
||||
Paint()..color = Colors.red.withValues(alpha: 0.6),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
|
||||
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final repository = context.read<SessionRepository>();
|
||||
final stats = await repository.getStatistics();
|
||||
final sessions = await repository.getAllSessions();
|
||||
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_stats = stats;
|
||||
@@ -125,21 +125,100 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final isSessionActive = sessionProvider.isSessionActive;
|
||||
|
||||
return ElevatedButton.icon(
|
||||
onPressed: () => _navigateToSessionSetup(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSessionActive ? AppTheme.secondaryColor : AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
// Si aucune session n'est active, on affiche juste le bouton de départ habituel
|
||||
if (!isSessionActive) {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: () => _navigateToSessionSetup(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: Icon(isSessionActive ? Icons.play_circle_outline : Icons.add_circle_outline, size: 28),
|
||||
label: Text(
|
||||
isSessionActive ? 'Continuer la session' : 'Démarrer la session',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
icon: const Icon(Icons.add_circle_outline, size: 28),
|
||||
label: const Text(
|
||||
'Nouvelle session',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// MODIFICATION : Alignement sur une seule ligne (80% / 20%) avec une croix pour l'annulation
|
||||
return Row(
|
||||
children: [
|
||||
// 80% de largeur pour continuer la session
|
||||
Expanded(
|
||||
flex: 8,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _navigateToSessionSetup(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_circle_outline, size: 28),
|
||||
label: const Text(
|
||||
'Continuer la session',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 20% de largeur pour terminer/annuler la session (Bouton Croix)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
// Boite de dialogue de confirmation pour éviter les erreurs
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Terminer la session ?'),
|
||||
content: const Text('Êtes-vous sûr de vouloir clôturer la session en cours ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('ANNULER'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
// Clôture propre de la session dans le State global
|
||||
context.read<SessionProvider>().endSession();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Session terminée avec succès !'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
side: const BorderSide(color: Colors.redAccent, width: 1.5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 20), // Même hauteur que le bouton continuer
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.close, size: 28),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,17 +276,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
child: _recentSessions.length < 2
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.show_chart, size: 40, color: Colors.grey),
|
||||
Text(
|
||||
'Pas assez de données',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.show_chart, size: 40, color: Colors.grey),
|
||||
Text(
|
||||
'Pas assez de données',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _buildHomeTrendChart(),
|
||||
),
|
||||
),
|
||||
@@ -276,7 +355,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final sorted = List<Session>.from(_recentSessions)
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
final display =
|
||||
sorted.length > 7 ? sorted.sublist(sorted.length - 7) : sorted;
|
||||
sorted.length > 7 ? sorted.sublist(sorted.length - 7) : sorted;
|
||||
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
@@ -372,4 +451,4 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
_loadStats();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import '../../data/models/target_analysis.dart';
|
||||
import '../../data/models/weapon.dart';
|
||||
|
||||
class SessionProvider extends ChangeNotifier {
|
||||
DateTime? _sessionDate;
|
||||
DateTime? get sessionDate => _sessionDate;
|
||||
String? _currentWeaponName;
|
||||
String? _currentWeaponId;
|
||||
int _shotsPerTarget = 5;
|
||||
@@ -23,11 +25,12 @@ class SessionProvider extends ChangeNotifier {
|
||||
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
|
||||
int get targetCount => _currentAnalyses.length;
|
||||
|
||||
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25}) {
|
||||
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) {
|
||||
_currentWeaponName = weaponName;
|
||||
_currentWeaponId = weaponId;
|
||||
_shotsPerTarget = shots;
|
||||
_distance = distance;
|
||||
_sessionDate = date ?? DateTime.now();
|
||||
_activeSessionId = sessionId;
|
||||
_currentAnalyses.clear();
|
||||
_isSessionActive = true;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/models/weapon.dart';
|
||||
import '../../data/repositories/session_repository.dart';
|
||||
import 'session_provider.dart';
|
||||
import '../capture/capture_screen.dart';
|
||||
import '../garage/weapon_list_screen.dart';
|
||||
|
||||
class SessionSetupScreen extends StatefulWidget {
|
||||
const SessionSetupScreen({super.key});
|
||||
@@ -16,13 +18,16 @@ class SessionSetupScreen extends StatefulWidget {
|
||||
|
||||
class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _weaponController = TextEditingController();
|
||||
|
||||
int _shotsPerTarget = 5;
|
||||
int _distance = 25;
|
||||
List<Weapon> _availableWeapons = [];
|
||||
Weapon? _selectedWeapon;
|
||||
bool _isLoadingWeapons = true;
|
||||
|
||||
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -30,34 +35,63 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadWeapons() async {
|
||||
setState(() => _isLoadingWeapons = true);
|
||||
final repository = context.read<SessionRepository>();
|
||||
final weapons = await repository.getWeapons();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableWeapons = weapons;
|
||||
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
|
||||
_selectedWeapon = _availableWeapons.first;
|
||||
_updateSettingsForWeapon(_selectedWeapon!);
|
||||
}
|
||||
_isLoadingWeapons = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_weaponController.dispose();
|
||||
super.dispose();
|
||||
void _updateSettingsForWeapon(Weapon weapon) {
|
||||
_shotsPerTarget = weapon.magazineCapacity;
|
||||
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
|
||||
}
|
||||
|
||||
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
|
||||
Future<void> _pickDate() async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
locale: const Locale('fr', 'FR'), // Force le calendrier en français
|
||||
);
|
||||
if (picked != null && picked != _selectedDate) {
|
||||
setState(() {
|
||||
// On garde aussi l'heure actuelle lors du changement de date
|
||||
final now = DateTime.now();
|
||||
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _startSession() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
if (_formKey.currentState!.validate() && _selectedWeapon != null) {
|
||||
final repository = context.read<SessionRepository>();
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
|
||||
|
||||
final sessionId = repository.generateId();
|
||||
|
||||
// PASSE-PARTOUT : On envoie la session au provider.
|
||||
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
|
||||
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
|
||||
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
|
||||
sessionProvider.startSession(
|
||||
_selectedWeapon != null ? _selectedWeapon!.displayName : _weaponController.text,
|
||||
_selectedWeapon!.displayName,
|
||||
_shotsPerTarget,
|
||||
sessionId,
|
||||
weaponId: _selectedWeapon?.id,
|
||||
weaponId: _selectedWeapon!.id,
|
||||
distance: _distance,
|
||||
date: _selectedDate,
|
||||
);
|
||||
|
||||
Navigator.pushReplacement(
|
||||
@@ -69,11 +103,14 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Petit formatage sympa en français (ex: "27 mai 2026")
|
||||
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Configuration de la session'),
|
||||
),
|
||||
body: _isLoadingWeapons
|
||||
body: _isLoadingWeapons
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
@@ -83,69 +120,97 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Choix de l\'arme',
|
||||
'Informations générales',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (_availableWeapons.isNotEmpty) ...[
|
||||
DropdownButtonFormField<Weapon?>(
|
||||
value: _selectedWeapon,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Sélectionner une arme de l\'armurerie',
|
||||
prefixIcon: const Icon(Icons.shield),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<Weapon?>(
|
||||
value: null,
|
||||
child: Text('Nouvelle arme / Autre'),
|
||||
),
|
||||
..._availableWeapons.map((w) => DropdownMenuItem(
|
||||
value: w,
|
||||
child: Text(w.displayName),
|
||||
)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedWeapon = value;
|
||||
if (value != null) {
|
||||
_weaponController.text = value.displayName;
|
||||
_shotsPerTarget = value.magazineCapacity;
|
||||
// Auto-set distance based on weapon type if needed
|
||||
_distance = (value.type == WeaponType.handgun) ? 25 : 50;
|
||||
} else {
|
||||
_weaponController.clear();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Center(child: Text('OU', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold))),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Weapon field (manual entry if no weapon selected)
|
||||
TextFormField(
|
||||
controller: _weaponController,
|
||||
enabled: _selectedWeapon == null,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nom de l\'arme',
|
||||
hintText: 'ex: Glock 17, CZ Shadow 2...',
|
||||
prefixIcon: const Icon(Icons.edit),
|
||||
border: OutlineInputBorder(
|
||||
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
|
||||
if (_availableWeapons.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.errorColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Ton armurerie est vide.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
|
||||
).then((_) {
|
||||
_loadWeapons();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('ALLER À MON ARMURERIE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
DropdownButtonFormField<Weapon>(
|
||||
value: _selectedWeapon,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Sélectionner une arme',
|
||||
prefixIcon: const Icon(Icons.shield),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
items: _availableWeapons.map((w) => DropdownMenuItem(
|
||||
value: w,
|
||||
child: Text(w.displayName),
|
||||
)).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedWeapon = value;
|
||||
_updateSettingsForWeapon(value);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// NOUVEAU CHAMP : Sélecteur de date interactif
|
||||
InkWell(
|
||||
onTap: _availableWeapons.isEmpty ? null : _pickDate,
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
child: IgnorePointer(
|
||||
child: TextFormField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Date de la session',
|
||||
prefixIcon: const Icon(Icons.calendar_today),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
controller: TextEditingController(text: formattedDate),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez renseigner votre arme';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Distance selector
|
||||
@@ -161,9 +226,9 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
value: _distance.toDouble(),
|
||||
min: 5,
|
||||
max: 300,
|
||||
divisions: 59, // (300-5)/5 = 59 divisions for 5m steps
|
||||
divisions: 59,
|
||||
label: '${_distance}m',
|
||||
onChanged: (value) {
|
||||
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
||||
setState(() {
|
||||
_distance = value.round();
|
||||
});
|
||||
@@ -190,10 +255,10 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// Shots per target
|
||||
const Text(
|
||||
'Nombre de balles par cible',
|
||||
'Nombre de balles pour la cible actuelle',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -203,10 +268,10 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
child: Slider(
|
||||
value: _shotsPerTarget.toDouble(),
|
||||
min: 1,
|
||||
max: 30,
|
||||
divisions: 29,
|
||||
max: 50,
|
||||
divisions: 49,
|
||||
label: '$_shotsPerTarget',
|
||||
onChanged: (value) {
|
||||
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
||||
setState(() {
|
||||
_shotsPerTarget = value.round();
|
||||
});
|
||||
@@ -233,9 +298,11 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
|
||||
ElevatedButton.icon(
|
||||
onPressed: _startSession,
|
||||
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
|
||||
? null
|
||||
: _startSession,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
@@ -256,4 +323,4 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
lib/services/document_scanner_service.dart
Normal file
54
lib/services/document_scanner_service.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
||||
|
||||
/// Résultat du scan de document.
|
||||
class DocumentScanResult {
|
||||
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
|
||||
final String? imagePath;
|
||||
|
||||
/// true si un scan a bien été produit.
|
||||
bool get success => imagePath != null;
|
||||
|
||||
const DocumentScanResult(this.imagePath);
|
||||
}
|
||||
|
||||
/// Service qui lance le scanner de documents Google ML Kit.
|
||||
///
|
||||
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
|
||||
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
|
||||
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
|
||||
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
|
||||
///
|
||||
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
|
||||
class DocumentScannerService {
|
||||
/// Lance le scanner et renvoie le chemin de l'image redressée.
|
||||
///
|
||||
/// [pageLimit] est fixé à 1 (une seule cible par scan).
|
||||
/// On désactive l'import galerie ici car la galerie est gérée séparément.
|
||||
///
|
||||
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
|
||||
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
|
||||
/// rehaussement type photocopie) → détruit les couleurs de la cible.
|
||||
/// - `base` ne fait QUE le recadrage + le redressement de perspective
|
||||
/// (mise de face), en conservant l'image en couleurs réelles. C'est
|
||||
/// indispensable pour pouvoir détecter ensuite les impacts.
|
||||
Future<DocumentScanResult> scanTarget() async {
|
||||
final options = DocumentScannerOptions(
|
||||
mode: ScannerMode.base, // redressement seul, sans filtre couleur
|
||||
pageLimit: 1,
|
||||
isGalleryImport: false,
|
||||
);
|
||||
|
||||
final scanner = DocumentScanner(options: options);
|
||||
try {
|
||||
final DocumentScanningResult result = await scanner.scanDocument();
|
||||
final images = result.images;
|
||||
if (images.isEmpty) {
|
||||
return const DocumentScanResult(null);
|
||||
}
|
||||
return DocumentScanResult(images.first);
|
||||
} finally {
|
||||
// Libère les ressources natives du scanner.
|
||||
await scanner.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,14 @@
|
||||
/// Service de recadrage d'images.
|
||||
///
|
||||
/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder
|
||||
/// dans un fichier temporaire pour utilisation ultérieure.
|
||||
library;
|
||||
|
||||
import 'dart:isolate'; // AJOUT
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Représente une zone de recadrage normalisée (0.0 à 1.0)
|
||||
class CropRect {
|
||||
/// Position X du coin supérieur gauche (0.0 à 1.0)
|
||||
final double x;
|
||||
|
||||
/// Position Y du coin supérieur gauche (0.0 à 1.0)
|
||||
final double y;
|
||||
|
||||
/// Largeur de la zone (0.0 à 1.0)
|
||||
final double width;
|
||||
|
||||
/// Hauteur de la zone (0.0 à 1.0)
|
||||
final double height;
|
||||
|
||||
const CropRect({
|
||||
@@ -35,97 +22,157 @@ class CropRect {
|
||||
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
|
||||
}
|
||||
|
||||
/// Service pour recadrer les images
|
||||
// AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable)
|
||||
class _CropParams {
|
||||
final String sourcePath;
|
||||
final double cropX;
|
||||
final double cropY;
|
||||
final double cropWidth;
|
||||
final double cropHeight;
|
||||
final double rotationDegrees;
|
||||
final int outputSize;
|
||||
final String outputPath;
|
||||
|
||||
_CropParams({
|
||||
required this.sourcePath,
|
||||
required this.cropX,
|
||||
required this.cropY,
|
||||
required this.cropWidth,
|
||||
required this.cropHeight,
|
||||
required this.rotationDegrees,
|
||||
required this.outputSize,
|
||||
required this.outputPath,
|
||||
});
|
||||
}
|
||||
|
||||
// AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static)
|
||||
void _cropIsolateEntry(_CropParams params) {
|
||||
final file = File(params.sourcePath);
|
||||
final bytes = file.readAsBytesSync();
|
||||
img.Image? originalImage = img.decodeImage(bytes);
|
||||
|
||||
if (originalImage == null) {
|
||||
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
|
||||
}
|
||||
|
||||
// ── OPTIMISATION MAJEURE : pré-réduction avant rotation ───────────────────
|
||||
// La rotation (copyRotate) est l'opération la plus lourde du package `image`
|
||||
// et son coût est proportionnel au nombre de pixels. Comme la sortie finale
|
||||
// ne fait que `outputSize` px, on n'a aucun intérêt à faire pivoter une image
|
||||
// de plusieurs dizaines de mégapixels.
|
||||
//
|
||||
// On réduit donc l'image source à une "taille de travail" juste suffisante
|
||||
// AVANT toute rotation/crop. Le crop garde ~85% du cadre, donc on laisse une
|
||||
// marge : workMax = outputSize / 0.7 couvre largement le besoin sans perte
|
||||
// visible. Les coordonnées de crop étant relatives (0..1), elles restent
|
||||
// valables quelle que soit l'échelle.
|
||||
final int workMax = (params.outputSize / 0.7).round();
|
||||
final int longestSide = math.max(originalImage.width, originalImage.height);
|
||||
if (longestSide > workMax) {
|
||||
if (originalImage.width >= originalImage.height) {
|
||||
originalImage = img.copyResize(
|
||||
originalImage,
|
||||
width: workMax,
|
||||
interpolation: img.Interpolation.linear,
|
||||
);
|
||||
} else {
|
||||
originalImage = img.copyResize(
|
||||
originalImage,
|
||||
height: workMax,
|
||||
interpolation: img.Interpolation.linear,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
|
||||
// (désormais appliquée sur une image déjà réduite → beaucoup plus rapide)
|
||||
if (params.rotationDegrees != 0.0) {
|
||||
originalImage = img.copyRotate(
|
||||
originalImage,
|
||||
angle: params.rotationDegrees,
|
||||
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
|
||||
);
|
||||
}
|
||||
|
||||
// Recadrage
|
||||
final srcX = (params.cropX * originalImage.width).round();
|
||||
final srcY = (params.cropY * originalImage.height).round();
|
||||
final srcWidth = (params.cropWidth * originalImage.width).round();
|
||||
final srcHeight = (params.cropHeight * originalImage.height).round();
|
||||
|
||||
final clampedX = srcX.clamp(0, originalImage.width - 1);
|
||||
final clampedY = srcY.clamp(0, originalImage.height - 1);
|
||||
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
|
||||
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
|
||||
|
||||
img.Image cropped = img.copyCrop(
|
||||
originalImage,
|
||||
x: clampedX,
|
||||
y: clampedY,
|
||||
width: clampedWidth,
|
||||
height: clampedHeight,
|
||||
);
|
||||
|
||||
// Redimensionnement — LINEAR au lieu de CUBIC
|
||||
if (cropped.width != params.outputSize || cropped.height != params.outputSize) {
|
||||
cropped = img.copyResize(
|
||||
cropped,
|
||||
width: params.outputSize,
|
||||
height: params.outputSize,
|
||||
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
|
||||
);
|
||||
}
|
||||
|
||||
// Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable)
|
||||
final outputFile = File(params.outputPath);
|
||||
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
||||
}
|
||||
|
||||
class ImageCropService {
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
/// Taille de sortie maximale pour les images recadrées
|
||||
static const int maxOutputSize = 1024;
|
||||
|
||||
/// Recadre une image en format carré
|
||||
///
|
||||
/// [sourcePath] - Chemin vers l'image source
|
||||
/// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0)
|
||||
/// [outputSize] - Taille de sortie en pixels (carré)
|
||||
///
|
||||
/// Retourne le chemin vers l'image recadrée dans le dossier temporaire
|
||||
Future<String> cropToSquare(
|
||||
String sourcePath,
|
||||
CropRect cropRect, {
|
||||
int outputSize = maxOutputSize,
|
||||
}) async {
|
||||
// Charger l'image source
|
||||
final file = File(sourcePath);
|
||||
final bytes = await file.readAsBytes();
|
||||
final originalImage = img.decodeImage(bytes);
|
||||
String sourcePath,
|
||||
CropRect cropRect, {
|
||||
double rotationDegrees = 0.0,
|
||||
int outputSize = maxOutputSize,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
|
||||
|
||||
if (originalImage == null) {
|
||||
throw Exception('Impossible de décoder l\'image: $sourcePath');
|
||||
}
|
||||
|
||||
// Calculer les coordonnées en pixels
|
||||
final srcX = (cropRect.x * originalImage.width).round();
|
||||
final srcY = (cropRect.y * originalImage.height).round();
|
||||
final srcWidth = (cropRect.width * originalImage.width).round();
|
||||
final srcHeight = (cropRect.height * originalImage.height).round();
|
||||
|
||||
// S'assurer que les dimensions sont valides
|
||||
final clampedX = srcX.clamp(0, originalImage.width - 1);
|
||||
final clampedY = srcY.clamp(0, originalImage.height - 1);
|
||||
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
|
||||
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
|
||||
|
||||
// Recadrer l'image
|
||||
img.Image cropped = img.copyCrop(
|
||||
originalImage,
|
||||
x: clampedX,
|
||||
y: clampedY,
|
||||
width: clampedWidth,
|
||||
height: clampedHeight,
|
||||
final params = _CropParams(
|
||||
sourcePath: sourcePath,
|
||||
cropX: cropRect.x,
|
||||
cropY: cropRect.y,
|
||||
cropWidth: cropRect.width,
|
||||
cropHeight: cropRect.height,
|
||||
rotationDegrees: rotationDegrees,
|
||||
outputSize: outputSize,
|
||||
outputPath: outputPath,
|
||||
);
|
||||
|
||||
// Redimensionner à la taille de sortie si nécessaire
|
||||
if (cropped.width != outputSize || cropped.height != outputSize) {
|
||||
cropped = img.copyResize(
|
||||
cropped,
|
||||
width: outputSize,
|
||||
height: outputSize,
|
||||
interpolation: img.Interpolation.cubic,
|
||||
);
|
||||
}
|
||||
|
||||
// Sauvegarder dans le dossier temporaire
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = 'cropped_${_uuid.v4()}.jpg';
|
||||
final outputPath = '${tempDir.path}/$fileName';
|
||||
|
||||
final outputFile = File(outputPath);
|
||||
await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90));
|
||||
// OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé
|
||||
// → le thread UI reste fluide pendant le calcul
|
||||
await Isolate.run(() => _cropIsolateEntry(params));
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// Calcule la zone de recadrage carrée maximale centrée sur l'image
|
||||
///
|
||||
/// [imageWidth] - Largeur de l'image en pixels
|
||||
/// [imageHeight] - Hauteur de l'image en pixels
|
||||
///
|
||||
/// Retourne un CropRect normalisé pour un carré centré
|
||||
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
|
||||
final aspectRatio = imageWidth / imageHeight;
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
// Image plus large que haute - centrer horizontalement
|
||||
final squareWidth = imageHeight / imageWidth;
|
||||
final x = (1 - squareWidth) / 2;
|
||||
return CropRect(x: x, y: 0, width: squareWidth, height: 1);
|
||||
} else if (aspectRatio < 1) {
|
||||
// Image plus haute que large - centrer verticalement
|
||||
final squareHeight = imageWidth / imageHeight;
|
||||
final y = (1 - squareHeight) / 2;
|
||||
return CropRect(x: 0, y: y, width: 1, height: squareHeight);
|
||||
} else {
|
||||
// Déjà carré
|
||||
return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
||||
}
|
||||
}
|
||||
@@ -147,7 +194,39 @@ class ImageCropService {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de nettoyage
|
||||
// Ignorer les erreurs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coupe automatiquement une photo brute de l'appareil photo en carré
|
||||
/// calé sur la position haute du viseur de l'écran.
|
||||
Future<String> cropRawCameraToSquare(String sourcePath) async {
|
||||
final file = File(sourcePath);
|
||||
final bytes = await file.readAsBytes();
|
||||
final originalImage = img.decodeImage(bytes);
|
||||
|
||||
if (originalImage == null) return sourcePath;
|
||||
|
||||
final int imgW = originalImage.width;
|
||||
final int imgH = originalImage.height;
|
||||
|
||||
final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round();
|
||||
|
||||
final int x = ((imgW - cropSizePixels) / 2).round();
|
||||
final int y = ((imgH - cropSizePixels) / 2).round();
|
||||
|
||||
final squareImage = img.copyCrop(
|
||||
originalImage,
|
||||
x: x.clamp(0, imgW - 1),
|
||||
y: y.clamp(0, imgH - 1),
|
||||
width: cropSizePixels,
|
||||
height: cropSizePixels,
|
||||
);
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85));
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
162
lib/services/parallelism_service.dart
Normal file
162
lib/services/parallelism_service.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
|
||||
/// Statut de parallélisme retourné en temps réel.
|
||||
enum ParallelismStatus {
|
||||
/// Pas encore de données capteur disponibles.
|
||||
unknown,
|
||||
|
||||
/// L'appareil est bien parallèle à la cible.
|
||||
aligned,
|
||||
|
||||
/// L'appareil est trop incliné.
|
||||
misaligned,
|
||||
}
|
||||
|
||||
/// Données de parallélisme calculées à chaque frame capteur.
|
||||
class ParallelismData {
|
||||
final ParallelismStatus status;
|
||||
|
||||
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
|
||||
final double pitchDegrees;
|
||||
|
||||
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
|
||||
final double rollDegrees;
|
||||
|
||||
const ParallelismData({
|
||||
required this.status,
|
||||
required this.pitchDegrees,
|
||||
required this.rollDegrees,
|
||||
});
|
||||
|
||||
bool get isAligned => status == ParallelismStatus.aligned;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
|
||||
}
|
||||
|
||||
/// Service de détection du parallélisme par accéléromètre.
|
||||
///
|
||||
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
|
||||
///
|
||||
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
|
||||
/// État actuel = aligned → passe à misaligned si angle > [misalignThreshold]
|
||||
///
|
||||
/// Cela crée une "zone de confort" entre les deux seuils où le statut
|
||||
/// ne change pas — le vert reste vert même si la main tremble légèrement.
|
||||
///
|
||||
/// Valeurs par défaut recommandées :
|
||||
/// alignThreshold = 15° (seuil d'entrée dans le vert — assez souple)
|
||||
/// misalignThreshold = 22° (seuil de sortie du vert — tolérant au tremblement)
|
||||
class ParallelismService {
|
||||
/// Angle max pour passer de misaligned → aligned.
|
||||
final double alignThreshold;
|
||||
|
||||
/// Angle à dépasser pour passer de aligned → misaligned.
|
||||
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
|
||||
final double misalignThreshold;
|
||||
|
||||
StreamSubscription<AccelerometerEvent>? _subscription;
|
||||
final StreamController<ParallelismData> _controller =
|
||||
StreamController<ParallelismData>.broadcast();
|
||||
|
||||
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
|
||||
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
|
||||
|
||||
ParallelismService({
|
||||
this.alignThreshold = 25.0,
|
||||
this.misalignThreshold = 32.0,
|
||||
}) : assert(
|
||||
misalignThreshold > alignThreshold,
|
||||
'misalignThreshold doit être supérieur à alignThreshold',
|
||||
);
|
||||
|
||||
Stream<ParallelismData> get stream => _controller.stream;
|
||||
|
||||
void start() {
|
||||
if (_subscription != null) return;
|
||||
|
||||
_subscription = accelerometerEventStream(
|
||||
samplingPeriod: SensorInterval.normalInterval, // ~50 ms
|
||||
).listen(
|
||||
_onAccelerometerEvent,
|
||||
onError: (_) {
|
||||
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
|
||||
if (!_controller.isClosed) {
|
||||
_currentStatus = ParallelismStatus.unknown;
|
||||
_controller.add(const ParallelismData(
|
||||
status: ParallelismStatus.unknown,
|
||||
pitchDegrees: 0,
|
||||
rollDegrees: 0,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_currentStatus = ParallelismStatus.unknown;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
stop();
|
||||
_controller.close();
|
||||
}
|
||||
|
||||
void _onAccelerometerEvent(AccelerometerEvent event) {
|
||||
if (_controller.isClosed) return;
|
||||
|
||||
final double gx = event.x;
|
||||
final double gy = event.y;
|
||||
final double gz = event.z;
|
||||
|
||||
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
|
||||
if (magnitude < 1.0) return; // Données aberrantes
|
||||
|
||||
// Normalisation par la magnitude réelle (indépendant de g exact)
|
||||
final double nx = gx / magnitude;
|
||||
final double ny = gy / magnitude;
|
||||
final double nz = gz / magnitude;
|
||||
|
||||
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
||||
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
||||
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
||||
|
||||
// Le critère de couleur = le PIRE des deux angles affichés à l'écran.
|
||||
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement
|
||||
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert.
|
||||
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs());
|
||||
|
||||
// ── Hystérésis ──────────────────────────────────────────────────────────
|
||||
// Premier appel : on décide selon alignThreshold uniquement
|
||||
if (_currentStatus == ParallelismStatus.unknown) {
|
||||
_currentStatus = worstAngle <= alignThreshold
|
||||
? ParallelismStatus.aligned
|
||||
: ParallelismStatus.misaligned;
|
||||
}
|
||||
// Déjà misaligned → devient aligned seulement si on passe sous alignThreshold
|
||||
else if (_currentStatus == ParallelismStatus.misaligned) {
|
||||
if (worstAngle <= alignThreshold) {
|
||||
_currentStatus = ParallelismStatus.aligned;
|
||||
}
|
||||
}
|
||||
// Déjà aligned → devient misaligned seulement si on dépasse misalignThreshold
|
||||
else if (_currentStatus == ParallelismStatus.aligned) {
|
||||
if (worstAngle > misalignThreshold) {
|
||||
_currentStatus = ParallelismStatus.misaligned;
|
||||
}
|
||||
// Entre alignThreshold et misalignThreshold → on garde le vert, on ne change rien
|
||||
}
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_controller.add(ParallelismData(
|
||||
status: _currentStatus,
|
||||
pitchDegrees: pitchDeg,
|
||||
rollDegrees: rollDeg,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,110 @@ export 'image_processing_service.dart'
|
||||
export 'opencv_impact_detection_service.dart'
|
||||
show OpenCVDetectionSettings, OpenCVDetectedImpact;
|
||||
|
||||
// ============================================================================
|
||||
// STRUCT ET FONCTION GLOBALE POUR LE PARALLÉLISME (THREAD SECONDAIRE)
|
||||
// ============================================================================
|
||||
|
||||
/// Conteneur de données pour envoyer les paramètres à l'Isolate d'arrière-plan.
|
||||
class DetectionPayload {
|
||||
final String imagePath;
|
||||
final TargetType targetType;
|
||||
|
||||
// On recrée les instances à l'intérieur du thread isolé car les objets ne se partagent pas entre threads.
|
||||
DetectionPayload({
|
||||
required this.imagePath,
|
||||
required this.targetType,
|
||||
});
|
||||
}
|
||||
|
||||
/// FONCTION EXÉCUTÉE EN PARALLÈLE : Tourne sur un autre cœur du processeur.
|
||||
/// L'interface graphique reste à 120 FPS et totalement fluide.
|
||||
TargetDetectionResult runParallelTargetDetection(DetectionPayload payload) {
|
||||
// 1. Initialisation locale des services dans le sous-thread
|
||||
final imageProcessingService = ImageProcessingService();
|
||||
|
||||
try {
|
||||
// 2. Détection de la cible principale (Calcul lourd)
|
||||
final mainTarget = imageProcessingService.detectMainTarget(payload.imagePath);
|
||||
|
||||
double centerX = 0.5;
|
||||
double centerY = 0.5;
|
||||
double radius = 0.4;
|
||||
|
||||
if (mainTarget != null) {
|
||||
centerX = mainTarget.centerX;
|
||||
centerY = mainTarget.centerY;
|
||||
radius = mainTarget.radius;
|
||||
}
|
||||
|
||||
// 3. Détection des impacts (Calcul lourd)
|
||||
final impacts = imageProcessingService.detectImpacts(payload.imagePath);
|
||||
|
||||
// 4. Calcul mathématique des scores relatifs
|
||||
final detectedImpacts = impacts.map((impact) {
|
||||
final score = payload.targetType == TargetType.concentric
|
||||
? _staticCalculateConcentricScore(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
)
|
||||
: _staticCalculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
radius: impact.radius,
|
||||
suggestedScore: score,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return TargetDetectionResult(
|
||||
centerX: centerX,
|
||||
centerY: centerY,
|
||||
radius: radius,
|
||||
impacts: detectedImpacts,
|
||||
);
|
||||
} catch (e) {
|
||||
return TargetDetectionResult.error('Erreur de detection parallèle: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Fonctions mathématiques pures nécessaires à l'Isolate (statiques)
|
||||
int _staticCalculateConcentricScore(double impactX, double impactY, double centerX, double centerY, double targetRadius) {
|
||||
final dx = impactX - centerX;
|
||||
final dy = impactY - centerY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
||||
if (distance <= 0.1) return 10;
|
||||
if (distance <= 0.2) return 9;
|
||||
if (distance <= 0.3) return 8;
|
||||
if (distance <= 0.4) return 7;
|
||||
if (distance <= 0.5) return 6;
|
||||
if (distance <= 0.6) return 5;
|
||||
if (distance <= 0.7) return 4;
|
||||
if (distance <= 0.8) return 3;
|
||||
if (distance <= 0.9) return 2;
|
||||
if (distance <= 1.0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int _staticCalculateSilhouetteScore(double impactX, double impactY, double centerX, double centerY) {
|
||||
final dx = (impactX - centerX).abs();
|
||||
final dy = impactY - centerY;
|
||||
if (dx > 0.15) return 0;
|
||||
if (dy < -0.25) return 5;
|
||||
if (dy < 0.0) return 5;
|
||||
if (dy < 0.15) return 4;
|
||||
if (dy < 0.35) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FIN DU BLOC DE PARALLÉLISME
|
||||
// ============================================================================
|
||||
|
||||
|
||||
class TargetDetectionResult {
|
||||
final double centerX; // Relative (0-1)
|
||||
final double centerY; // Relative (0-1)
|
||||
@@ -59,13 +163,20 @@ class TargetDetectionService {
|
||||
ImageProcessingService? imageProcessingService,
|
||||
OpenCVImpactDetectionService? opencvService,
|
||||
}) : _imageProcessingService =
|
||||
imageProcessingService ?? ImageProcessingService(),
|
||||
_opencvService = opencvService ?? OpenCVImpactDetectionService();
|
||||
imageProcessingService ?? ImageProcessingService(),
|
||||
_opencvService = opencvService ?? OpenCVImpactDetectionService();
|
||||
|
||||
/// Detect target and impacts from an image file
|
||||
/// Detect target and impacts from an image file ASYNCHRONOUSLY in a separate Thread.
|
||||
/// CORRECTION : Utilise désormais 'compute' pour basculer en arrière-plan immédiat.
|
||||
Future<TargetDetectionResult> detectTargetAsync(String imagePath, TargetType targetType) async {
|
||||
final payload = DetectionPayload(imagePath: imagePath, targetType: targetType);
|
||||
// Déclenche l'exécution isolée en tâche de fond
|
||||
return await compute(runParallelTargetDetection, payload);
|
||||
}
|
||||
|
||||
/// Gardée pour rétrocompatibilité synchrone si nécessaire
|
||||
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
|
||||
try {
|
||||
// Detect main target
|
||||
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
|
||||
|
||||
double centerX = 0.5;
|
||||
@@ -78,19 +189,17 @@ class TargetDetectionService {
|
||||
radius = mainTarget.radius;
|
||||
}
|
||||
|
||||
// Detect impacts
|
||||
final impacts = _imageProcessingService.detectImpacts(imagePath);
|
||||
|
||||
// Convert impacts to relative coordinates and calculate scores
|
||||
final detectedImpacts = impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScore(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
)
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
@@ -113,18 +222,16 @@ class TargetDetectionService {
|
||||
}
|
||||
|
||||
int _calculateConcentricScore(
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double targetRadius,
|
||||
) {
|
||||
// Calculate distance from center (normalized to target radius)
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double targetRadius,
|
||||
) {
|
||||
final dx = impactX - centerX;
|
||||
final dy = impactY - centerY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
||||
|
||||
// Score zones (10 zones)
|
||||
if (distance <= 0.1) return 10;
|
||||
if (distance <= 0.2) return 9;
|
||||
if (distance <= 0.3) return 8;
|
||||
@@ -135,61 +242,52 @@ class TargetDetectionService {
|
||||
if (distance <= 0.8) return 3;
|
||||
if (distance <= 0.9) return 2;
|
||||
if (distance <= 1.0) return 1;
|
||||
return 0; // Outside target
|
||||
return 0;
|
||||
}
|
||||
|
||||
int _calculateSilhouetteScore(
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
) {
|
||||
// For silhouettes, scoring is typically based on zones
|
||||
// Head and center mass = 5, body = 4, lower = 3
|
||||
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
) {
|
||||
final dx = (impactX - centerX).abs();
|
||||
final dy = impactY - centerY;
|
||||
|
||||
// Check if within silhouette bounds (approximate)
|
||||
if (dx > 0.15) return 0; // Too far left/right
|
||||
if (dx > 0.15) return 0;
|
||||
if (dy < -0.25) return 5;
|
||||
if (dy < 0.0) return 5;
|
||||
if (dy < 0.15) return 4;
|
||||
if (dy < 0.35) return 3;
|
||||
|
||||
// Vertical zones
|
||||
if (dy < -0.25) return 5; // Head zone (top)
|
||||
if (dy < 0.0) return 5; // Center mass (upper body)
|
||||
if (dy < 0.15) return 4; // Body
|
||||
if (dy < 0.35) return 3; // Lower body
|
||||
|
||||
return 0; // Outside target
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Detect only impacts with custom settings (doesn't affect target position)
|
||||
List<DetectedImpactResult> detectImpactsOnly(
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
ImpactDetectionSettings settings,
|
||||
) {
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
ImpactDetectionSettings settings,
|
||||
) {
|
||||
try {
|
||||
// Detect impacts with custom settings
|
||||
final impacts = _imageProcessingService.detectImpactsWithSettings(
|
||||
imagePath,
|
||||
settings,
|
||||
);
|
||||
|
||||
// Convert impacts to relative coordinates and calculate scores
|
||||
return impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScoreWithRings(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
@@ -205,19 +303,17 @@ class TargetDetectionService {
|
||||
}
|
||||
|
||||
int _calculateConcentricScoreWithRings(
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double targetRadius,
|
||||
int ringCount,
|
||||
) {
|
||||
// Calculate distance from center (normalized to target radius)
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double targetRadius,
|
||||
int ringCount,
|
||||
) {
|
||||
final dx = impactX - centerX;
|
||||
final dy = impactY - centerY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
||||
|
||||
// Score zones based on ringCount
|
||||
for (int i = 0; i < ringCount; i++) {
|
||||
final zoneRadius = (i + 1) / ringCount;
|
||||
if (distance <= zoneRadius) {
|
||||
@@ -225,31 +321,29 @@ class TargetDetectionService {
|
||||
}
|
||||
}
|
||||
|
||||
return 0; // Outside target
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Analyze reference impacts to learn their characteristics
|
||||
ImpactCharacteristics? analyzeReferenceImpacts(
|
||||
String imagePath,
|
||||
List<ReferenceImpact> references,
|
||||
) {
|
||||
String imagePath,
|
||||
List<ReferenceImpact> references,
|
||||
) {
|
||||
return _imageProcessingService.analyzeReferenceImpacts(
|
||||
imagePath,
|
||||
references,
|
||||
);
|
||||
}
|
||||
|
||||
/// Detect impacts based on reference characteristics (calibrated detection)
|
||||
List<DetectedImpactResult> detectImpactsFromReferences(
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
ImpactCharacteristics characteristics, {
|
||||
double tolerance = 2.0,
|
||||
}) {
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
ImpactCharacteristics characteristics, {
|
||||
double tolerance = 2.0,
|
||||
}) {
|
||||
try {
|
||||
final impacts = _imageProcessingService.detectImpactsFromReferences(
|
||||
imagePath,
|
||||
@@ -260,13 +354,13 @@ class TargetDetectionService {
|
||||
return impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScoreWithRings(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
@@ -281,20 +375,15 @@ class TargetDetectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Détecte les impacts en utilisant OpenCV (Hough Circles + Contours)
|
||||
///
|
||||
/// Cette méthode utilise les algorithmes OpenCV pour une détection plus robuste:
|
||||
/// - Transformation de Hough pour détecter les cercles
|
||||
/// - Analyse de contours avec filtrage par circularité
|
||||
List<DetectedImpactResult> detectImpactsWithOpenCV(
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount, {
|
||||
OpenCVDetectionSettings? settings,
|
||||
}) {
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount, {
|
||||
OpenCVDetectionSettings? settings,
|
||||
}) {
|
||||
try {
|
||||
final impacts = _opencvService.detectImpacts(
|
||||
imagePath,
|
||||
@@ -304,13 +393,13 @@ class TargetDetectionService {
|
||||
return impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScoreWithRings(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
@@ -326,22 +415,17 @@ class TargetDetectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Détecte les impacts avec OpenCV en utilisant des références
|
||||
///
|
||||
/// Analyse les impacts de référence pour apprendre leurs caractéristiques
|
||||
/// puis détecte les impacts similaires dans l'image.
|
||||
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
List<ReferenceImpact> references, {
|
||||
double tolerance = 2.0,
|
||||
}) {
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
List<ReferenceImpact> references, {
|
||||
double tolerance = 2.0,
|
||||
}) {
|
||||
try {
|
||||
// Convertir les références au format OpenCV
|
||||
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
|
||||
|
||||
final impacts = _opencvService.detectFromReferences(
|
||||
@@ -353,13 +437,13 @@ class TargetDetectionService {
|
||||
return impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScoreWithRings(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
@@ -374,5 +458,4 @@ class TargetDetectionService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
259
lib/services/target_rectify_service.dart
Normal file
259
lib/services/target_rectify_service.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||
|
||||
/// Résultat d'une tentative de redressement de cible.
|
||||
class RectifyResult {
|
||||
/// Chemin du fichier image redressé (ou original si échec).
|
||||
final String outputPath;
|
||||
|
||||
/// true si une cible a été détectée et redressée, false si on a renvoyé
|
||||
/// l'image d'origine sans transformation.
|
||||
final bool rectified;
|
||||
|
||||
/// Angle d'inclinaison estimé de la cible AVANT redressement, en degrés.
|
||||
/// (0 = déjà de face). Utile pour informer l'utilisateur.
|
||||
final double estimatedTiltDegrees;
|
||||
|
||||
/// Message de diagnostic (utile pour debug / affichage).
|
||||
final String message;
|
||||
|
||||
const RectifyResult({
|
||||
required this.outputPath,
|
||||
required this.rectified,
|
||||
required this.estimatedTiltDegrees,
|
||||
required this.message,
|
||||
});
|
||||
}
|
||||
|
||||
/// Service qui redresse une cible RONDE (cercles concentriques) photographiée
|
||||
/// de biais, en la ramenant parfaitement de face.
|
||||
///
|
||||
/// Principe :
|
||||
/// 1. Détecter le plus grand contour ~circulaire de l'image.
|
||||
/// 2. Ajuster une ELLIPSE sur ce contour (fitEllipse).
|
||||
/// 3. Un cercle vu en perspective devient une ellipse : on calcule la
|
||||
/// transformation de perspective qui remappe cette ellipse vers un
|
||||
/// CERCLE parfait, et on l'applique à toute l'image.
|
||||
/// 4. La cible apparaît alors de face.
|
||||
///
|
||||
/// L'image résultat est carrée et centrée sur la cible.
|
||||
class TargetRectifyService {
|
||||
/// Taille (en pixels) du côté de l'image carrée de sortie.
|
||||
final int outputSize;
|
||||
|
||||
/// Marge autour de la cible dans l'image de sortie (1.0 = cible pile au bord,
|
||||
/// 1.3 = 30 % de marge autour). Garde un peu de contexte.
|
||||
final double marginFactor;
|
||||
|
||||
/// En dessous de cet écart d'axes (ratio petit/grand axe proche de 1),
|
||||
/// la cible est considérée déjà de face → pas de warp inutile.
|
||||
final double minTiltRatioToRectify;
|
||||
|
||||
TargetRectifyService({
|
||||
this.outputSize = 1024,
|
||||
this.marginFactor = 1.25,
|
||||
this.minTiltRatioToRectify = 0.985,
|
||||
});
|
||||
|
||||
/// Redresse l'image située à [inputPath]. Écrit le résultat dans
|
||||
/// [outputPath] et renvoie un [RectifyResult].
|
||||
///
|
||||
/// Ne bloque jamais : en cas d'échec de détection, renvoie l'image
|
||||
/// d'origine (rectified = false) pour ne pas perdre la photo du tireur.
|
||||
Future<RectifyResult> rectify({
|
||||
required String inputPath,
|
||||
required String outputPath,
|
||||
}) async {
|
||||
cv.Mat? src;
|
||||
cv.Mat? gray;
|
||||
cv.Mat? blurred;
|
||||
cv.Mat? edges;
|
||||
try {
|
||||
src = cv.imread(inputPath, flags: cv.IMREAD_COLOR);
|
||||
if (src.isEmpty) {
|
||||
return RectifyResult(
|
||||
outputPath: inputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: 0,
|
||||
message: 'Image illisible',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 1. Prétraitement ────────────────────────────────────────────────
|
||||
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
|
||||
blurred = cv.gaussianBlur(gray, (5, 5), 2, sigmaY: 2);
|
||||
edges = cv.canny(blurred, 60, 160);
|
||||
|
||||
// Dilatation légère pour fermer les contours brisés
|
||||
final cv.Mat kernel = cv.getStructuringElement(
|
||||
cv.MORPH_ELLIPSE,
|
||||
(3, 3),
|
||||
);
|
||||
final cv.Mat dilated = cv.dilate(edges, kernel);
|
||||
|
||||
// ── 2. Recherche du meilleur contour elliptique ──────────────────────
|
||||
final (contours, _) = cv.findContours(
|
||||
dilated,
|
||||
cv.RETR_EXTERNAL,
|
||||
cv.CHAIN_APPROX_SIMPLE,
|
||||
);
|
||||
|
||||
final double imgArea = (src.width * src.height).toDouble();
|
||||
cv.RotatedRect? bestEllipse;
|
||||
double bestScore = 0;
|
||||
|
||||
for (int i = 0; i < contours.length; i++) {
|
||||
final c = contours[i];
|
||||
if (c.length < 5) continue; // fitEllipse exige >= 5 points
|
||||
|
||||
final double area = cv.contourArea(c);
|
||||
// On ignore les contours minuscules et ceux qui couvrent presque tout
|
||||
if (area < imgArea * 0.03 || area > imgArea * 0.97) continue;
|
||||
|
||||
final cv.RotatedRect e = cv.fitEllipse(c);
|
||||
final ep = e.points; // 4 sommets
|
||||
if (ep.length < 4) continue;
|
||||
final double ecx = (ep[0].x + ep[1].x + ep[2].x + ep[3].x) / 4.0;
|
||||
final double ecy = (ep[0].y + ep[1].y + ep[2].y + ep[3].y) / 4.0;
|
||||
final double mAx = (ep[0].x + ep[1].x) / 2.0;
|
||||
final double mAy = (ep[0].y + ep[1].y) / 2.0;
|
||||
final double mBx = (ep[1].x + ep[2].x) / 2.0;
|
||||
final double mBy = (ep[1].y + ep[2].y) / 2.0;
|
||||
final double w =
|
||||
2 * math.sqrt(math.pow(mAx - ecx, 2) + math.pow(mAy - ecy, 2));
|
||||
final double h =
|
||||
2 * math.sqrt(math.pow(mBx - ecx, 2) + math.pow(mBy - ecy, 2));
|
||||
if (w <= 1 || h <= 1) continue;
|
||||
|
||||
// À quel point le contour ressemble-t-il vraiment à son ellipse ?
|
||||
// On compare l'aire du contour à l'aire de l'ellipse ajustée.
|
||||
final double ellipseArea = math.pi * (w / 2) * (h / 2);
|
||||
if (ellipseArea <= 0) continue;
|
||||
final double fitRatio = area / ellipseArea; // ~1 si bon ajustement
|
||||
if (fitRatio < 0.7 || fitRatio > 1.3) continue;
|
||||
|
||||
// Score = taille de l'ellipse (on veut la cible la plus grande)
|
||||
final double score = ellipseArea;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestEllipse = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestEllipse == null) {
|
||||
return RectifyResult(
|
||||
outputPath: inputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: 0,
|
||||
message: 'Aucune cible circulaire détectée',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Extraire les 4 sommets de l'ellipse (robuste à la version d'API) ─
|
||||
// RotatedRect.points renvoie les 4 coins de la boîte englobant l'ellipse.
|
||||
// On en dérive nous-mêmes le centre, les demi-axes et l'orientation, ce
|
||||
// qui évite de dépendre de la forme exacte de `.size` / `.center`
|
||||
// (record vs objet) qui varie selon les versions d'opencv_dart.
|
||||
final pts = bestEllipse.points; // List<Point2f> de 4 sommets
|
||||
|
||||
double px(int i) => pts[i].x;
|
||||
double py(int i) => pts[i].y;
|
||||
|
||||
// Centre = moyenne des 4 sommets
|
||||
final double cx = (px(0) + px(1) + px(2) + px(3)) / 4.0;
|
||||
final double cy = (py(0) + py(1) + py(2) + py(3)) / 4.0;
|
||||
|
||||
// Milieux de deux côtés adjacents → extrémités des deux demi-axes
|
||||
// Côté 0-1 et côté 1-2 (ordre des sommets d'un RotatedRect)
|
||||
final double m01x = (px(0) + px(1)) / 2.0;
|
||||
final double m01y = (py(0) + py(1)) / 2.0;
|
||||
final double m12x = (px(1) + px(2)) / 2.0;
|
||||
final double m12y = (py(1) + py(2)) / 2.0;
|
||||
|
||||
// Demi-axes = distance centre → milieu de chaque côté
|
||||
final double axisA =
|
||||
math.sqrt(math.pow(m01x - cx, 2) + math.pow(m01y - cy, 2));
|
||||
final double axisB =
|
||||
math.sqrt(math.pow(m12x - cx, 2) + math.pow(m12y - cy, 2));
|
||||
|
||||
final double majorAxis = math.max(axisA, axisB);
|
||||
final double minorAxis = math.min(axisA, axisB);
|
||||
if (majorAxis <= 1) {
|
||||
return RectifyResult(
|
||||
outputPath: inputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: 0,
|
||||
message: 'Ellipse dégénérée',
|
||||
);
|
||||
}
|
||||
|
||||
final double axisRatio = minorAxis / majorAxis; // 1 = cercle parfait
|
||||
final double tiltDeg =
|
||||
math.acos(axisRatio.clamp(0.0, 1.0)) * (180.0 / math.pi);
|
||||
|
||||
// Déjà quasiment de face → on ne touche pas (évite le flou inutile)
|
||||
if (axisRatio >= minTiltRatioToRectify) {
|
||||
cv.imwrite(outputPath, src);
|
||||
return RectifyResult(
|
||||
outputPath: outputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: tiltDeg,
|
||||
message: 'Cible déjà de face',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Construire la transformation de perspective ────────────────────
|
||||
// Source : extrémités des deux axes de l'ellipse (4 points).
|
||||
// Destination : extrémités des axes d'un cercle parfait centré.
|
||||
// On obtient les extrémités en prolongeant centre→milieu-de-côté.
|
||||
final srcPts = cv.VecPoint.fromList([
|
||||
cv.Point((cx + (m01x - cx)).round(), (cy + (m01y - cy)).round()),
|
||||
cv.Point((cx - (m01x - cx)).round(), (cy - (m01y - cy)).round()),
|
||||
cv.Point((cx + (m12x - cx)).round(), (cy + (m12y - cy)).round()),
|
||||
cv.Point((cx - (m12x - cx)).round(), (cy - (m12y - cy)).round()),
|
||||
]);
|
||||
|
||||
// Cible : cercle parfait centré, rayon R, dans une image carrée.
|
||||
// L'axe "A" (m01) devient l'axe horizontal, l'axe "B" (m12) le vertical.
|
||||
final double out = outputSize.toDouble();
|
||||
final double center = out / 2;
|
||||
final double radius = (out / 2) / marginFactor;
|
||||
final dstPts = cv.VecPoint.fromList([
|
||||
cv.Point((center + radius).round(), center.round()),
|
||||
cv.Point((center - radius).round(), center.round()),
|
||||
cv.Point(center.round(), (center + radius).round()),
|
||||
cv.Point(center.round(), (center - radius).round()),
|
||||
]);
|
||||
|
||||
final cv.Mat transform = cv.getPerspectiveTransform(srcPts, dstPts);
|
||||
|
||||
final cv.Mat warped = cv.warpPerspective(
|
||||
src,
|
||||
transform,
|
||||
(outputSize, outputSize),
|
||||
flags: cv.INTER_LINEAR,
|
||||
borderMode: cv.BORDER_CONSTANT,
|
||||
);
|
||||
|
||||
cv.imwrite(outputPath, warped);
|
||||
|
||||
return RectifyResult(
|
||||
outputPath: outputPath,
|
||||
rectified: true,
|
||||
estimatedTiltDegrees: tiltDeg,
|
||||
message: 'Cible redressée (inclinaison ${tiltDeg.toStringAsFixed(1)}°)',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('TargetRectify erreur: $e');
|
||||
// En cas de pépin, on renvoie l'original pour ne jamais perdre la photo
|
||||
return RectifyResult(
|
||||
outputPath: inputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: 0,
|
||||
message: 'Erreur de traitement: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
pubspec.lock
106
pubspec.lock
@@ -33,6 +33,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
camera:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: camera
|
||||
sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.0+1"
|
||||
camera_android_camerax:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_android_camerax
|
||||
sha256: b5064cf25a2787d122d0bf12e77c7b1033a2b983d0730e3091f770ee376efde5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
camera_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_avfoundation
|
||||
sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
camera_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_platform_interface
|
||||
sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
camera_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_web
|
||||
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+3"
|
||||
change_case:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -45,10 +85,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -253,6 +293,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
google_mlkit_commons:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_mlkit_commons
|
||||
sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
google_mlkit_document_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -261,6 +309,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
google_mlkit_object_detection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_mlkit_object_detection
|
||||
sha256: "9dd35886972e18747e22098f8ebee78d30716a99a789bb2e3a65a24229e031e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.1"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -425,26 +481,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -661,6 +717,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
sensors_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sensors_plus
|
||||
sha256: "8e7fa79b4940442bb595bfc0ee9da4af5a22a0fe6ebacc74998245ee9496a82d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
sensors_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sensors_plus_platform_interface
|
||||
sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -802,6 +874,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -814,10 +894,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0+1"
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -830,10 +910,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.7"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -915,5 +995,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
@@ -36,8 +36,9 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
sensors_plus: ^4.0.2
|
||||
opencv_dart: ^2.1.0
|
||||
google_mlkit_object_detection: ^0.15.0
|
||||
|
||||
# Image capture from camera/gallery
|
||||
image_picker: ^1.2.1
|
||||
@@ -70,6 +71,7 @@ dependencies:
|
||||
device_info_plus: ^13.1.0
|
||||
shared_preferences: ^2.5.5
|
||||
crypto: ^3.0.7
|
||||
camera: ^0.12.0+1
|
||||
|
||||
# Machine Learning for YOLOv8
|
||||
# tflite_flutter: ^0.11.0
|
||||
|
||||
Reference in New Issue
Block a user