18 Commits

Author SHA1 Message Date
qguillaume
8946955f76 feat: passage du filtre date à une sélection de période (DateRange) dans l'historique 2026-05-06 23:17:22 +02:00
qguillaume
be62669d56 feat: intégration de la vue validation image (20) 2026-05-06 23:05:13 +02:00
qguillaume
db6dd0cee5 feat: refonte des statistiques (00C) et correction ergonomique de la capture (10C) 2026-05-06 22:39:30 +02:00
qguillaume
d98f54cf93 Ajout de la gestion des permissions photos et configuration native Android/iOS 2026-05-05 18:05:49 +02:00
qguillaume
55f6210c97 feat: change titre et ajout nettoyer objectif dans capture screen 2026-05-05 16:53:29 +02:00
qguillaume
8d9b35104a Fix: Intégration du DatePicker français et mise à jour des dépendances SDK 2026-05-05 16:36:14 +02:00
qguillaume
5cc67228e4 MAJ0.1 accueil et historique 2026-05-03 16:46:05 +02:00
streaper2
105eb1cab0 correction suppression modele yolo 2026-04-29 10:12:29 +02:00
streaper2
fba3b41f2f desactivation de tous les scripts et ia, supression du modele yolo 2026-04-28 16:52:20 +02:00
streaper2
e32833e366 preparation du modele yolo 2026-03-12 22:03:40 +01:00
streaper2
d4cb179fde correction du centrage des vecteurs de zone (cercle) pour le plotting 2026-02-21 10:22:18 +01:00
streaper2
2e81f4b69e Merge branch 'temp-fix'
detached branch a merge pour récupérer le travail
2026-02-15 22:21:31 +01:00
streaper2
de677aad7e désactivation autocalibration, et correction de distortion dans l'écran de calibration 2026-02-15 22:07:35 +01:00
streaper2
723900b860 Autocalibration de la cible sur le centre de la cible ok + petite correction de la distortion mais pas tres fonctionnel 2026-02-15 09:49:48 +01:00
streaper2
f78184d2cd nettoyage du code avec cunning 2026-02-14 22:33:35 +01:00
streaper2
7e55c52ae7 implementation de cunning pour scanner l'image et la redresser 2026-02-13 22:18:35 +01:00
streaper2
049f77ac6d ajout doc interne a l'appli 2026-02-06 13:40:01 +01:00
qguillaume
91fcd931e8 clickable home screen renamed 2026-02-03 12:16:30 +01:00
40 changed files with 2592 additions and 1470 deletions

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"avdmanager.executable": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\avdmanager.bat",
"avdmanager.sdkManager": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\sdkmanager.bat"
}

31
analyze_log.txt Normal file
View File

@@ -0,0 +1,31 @@
Analyzing bully...
info - Statements in an if should be enclosed in a block - lib\features\analysis\analysis_screen.dart:122:17 - curly_braces_in_flow_control_structures
info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\features\analysis\analysis_screen.dart:650:51 - deprecated_member_use
warning - The declaration '_showAddShotHint' isn't referenced - lib\features\analysis\analysis_screen.dart:1083:8 - unused_element
warning - The declaration '_showAutoDetectDialog' isn't referenced - lib\features\analysis\analysis_screen.dart:1120:8 - unused_element
warning - Unused import: 'widgets/target_type_selector.dart' - lib\features\capture\capture_screen.dart:16:8 - unused_import
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:28:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:141:25 - deprecated_member_use
info - The import of 'package:flutter/foundation.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/material.dart' - lib\features\statistics\statistics_screen.dart:8:8 - unnecessary_import
warning - The declaration '_buildLegendItem' isn't referenced - lib\features\statistics\statistics_screen.dart:309:10 - unused_element
info - Unnecessary use of string interpolation - lib\features\statistics\statistics_screen.dart:408:15 - unnecessary_string_interpolations
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\opencv_impact_detection_service.dart:104:5 - avoid_print
info - Don't invoke 'print' in production code - lib\services\opencv_impact_detection_service.dart:116:5 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:297:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:342:7 - avoid_print
27 issues found. (ran in 1.9s)

BIN
analyze_opencv.txt Normal file

Binary file not shown.

BIN
analyze_output.txt Normal file

Binary file not shown.

BIN
analyze_results_final.txt Normal file

Binary file not shown.

37
analyze_results_fixed.txt Normal file
View File

@@ -0,0 +1,37 @@
Analyzing bully...
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:553:7 - avoid_print
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print

38
analyze_utf8.txt Normal file
View File

@@ -0,0 +1,38 @@
Analyzing bully...
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:392:7 - avoid_print
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:596:7 - avoid_print
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print

View File

@@ -35,6 +35,7 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
} }
} }

7
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,7 @@
# TensorFlow Lite
-keep class org.tensorflow.lite.** { *; }
-keep class com.google.android.gms.tflite.** { *; }
-dontwarn org.tensorflow.lite.**
# Specifically for the GPU delegate error
-dontwarn org.tensorflow.lite.gpu.GpuDelegateFactory$Options

View File

@@ -1,4 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<!-- Pour Android 12 et inférieur -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- Pour Android 13 et supérieur -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application <application
android:label="bully" android:label="bully"
android:name="${applicationName}" android:name="${applicationName}"

20
build_log.txt Normal file
View File

@@ -0,0 +1,20 @@
Running Gradle task 'assembleDebug'...
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:processDebugResources'.
> A failure occurred while executing com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$TaskAction
> Android resource linking failed
ERROR: C:\Users\streaper2\Documents\00 - projet\bully\build\cunning_document_scanner\intermediates\merged_manifest\debug\processDebugManifest\AndroidManifest.xml:9:5-65: AAPT: error: unexpected element <uses-permission> found in <manifest><application>.
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 5s
Running Gradle task 'assembleDebug'... 5,4s
Gradle task assembleDebug failed with exit code 1

26
docs/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Documentation du Projet Bully
Bienvenue dans la documentation développeur de l'application **Bully**.
Ce projet est une application Flutter d'analyse de cibles de tir (Impact Detection).
## Architecture
Le code source est organisé dans le dossier `lib/` selon les couches suivantes :
- **Features (`lib/features`)** : Contient les écrans et la logique UI (Vues/Pages). C'est ici que réside l'interface utilisateur.
- **Services (`lib/services`)** : Services "métier" et utilitaires (traitement d'image, calculs, etc.). Indépendant de l'UI.
- **Data (`lib/data`)** : Gestion des données (Modèles, Base de données locale, Repositories).
## Sections de la Documentation
Pour plus de détails sur chaque partie, consultez les sections dédiées :
- 🏗️ **[Services (Logique Métier)](services/README.md)** : Documentation des services comme le traitement d'image et le calcul de score.
- 📱 **[Vues & Features (UI)](features/README.md)** : Documentation des écrans principaux (ex: Analyse).
- 💾 **[Base de Données & Modèles](data/README.md)** : Structure des données et persistance.
## Pour commencer
1. Assurez-vous d'avoir Flutter installé.
2. Lancez `flutter run` pour démarrer l'application.

17
docs/data/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Data & Persistance
Cette couche gère la sauvegarde et la récupération des données.
## Base de Données
L'application utilise une base de données locale (probablement SQLite/Drift ou Hive, à vérifier dans `lib/data/database`).
## Modèles (`lib/data/models`)
Les classes représentant les objets métier persistés.
Exemples probables :
- `Session` : Une session de tir.
- `Impact` : Un impact de balle sur la cible.
- `Target` : Configuration d'une cible.
## Repositories (`lib/data/repositories`)
Le pattern Repository est utilisé pour abstraire la source de données (DB locale, API distante, etc.) du reste de l'application.

17
docs/features/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Features & Vues
Cette section documente les écrans principaux de l'application et leur organisation.
## Écrans Principaux
### Analysis (`lib/features/analysis`)
C'est le cœur de l'application. Il permet à l'utilisateur de prendre une photo ou choisir une image pour analyser les impacts.
- **AnalysisScreen** (`analysis_screen.dart`): L'écran principal qui orchestre la capture et l'affichage des résultats.
- **AnalysisProvider** (`analysis_provider.dart`): Gestionnaire d'état (State Management) pour cet écran. Il fait le pont entre la vue et les services.
## Structure d'une Feature
Chaque feature est généralement composée de :
- `_screen.dart` : Le Widget de la page.
- `_provider.dart` : La logique d'état (ChangeNotifier, Bloc, etc.).
- `widgets/` : Widgets spécifiques à cette feature.

20
docs/services/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Services
Les services contiennent la logique métier de l'application, isolée de l'interface utilisateur.
## Liste des Services Principaux
| Service | Description | Fichier |
| :--- | :--- | :--- |
| **ImageProcessingService** | Gère le traitement lourd des images (filtres, détection). | `lib/services/image_processing_service.dart` |
| **DistortionCorrection** | Corrige la distorsion de perspective des cibles. | `lib/services/distortion_correction_service.dart` |
| **ScoreCalculator** | Calcule le score en fonction des impacts détectés. | `lib/services/score_calculator_service.dart` |
| **StatisticsService** | Génère des statistiques sur les sessions de tir. | `lib/services/statistics_service.dart` |
## Exemple d'utilisation (Fictif)
```dart
// Exemple d'appel au service de calcul de score
final calculator = ScoreCalculatorService();
final score = calculator.calculate(impacts);
```

View File

@@ -2,6 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>Bully a besoin d'accéder à vos photos pour analyser vos cibles enregistrées.</string>
<key>NSCameraUsageDescription</key>
<string>Bully a besoin d'utiliser l'appareil photo pour scanner vos cibles en temps réel.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Bully a besoin d'accéder au micro (requis par certains services de caméra même si non utilisé).</string>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class BullyApp extends StatelessWidget { class BullyApp extends StatelessWidget {
const BullyApp({super.key}); const BullyApp({super.key});
@@ -13,6 +14,16 @@ class BullyApp extends StatelessWidget {
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme, darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('fr', 'FR'), // Français
],
locale: const Locale('fr', 'FR'), // Force l'interface en français
home: const HomeScreen(), home: const HomeScreen(),
); );
} }

View File

@@ -17,6 +17,7 @@ import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart'; import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
import '../../services/distortion_correction_service.dart'; import '../../services/distortion_correction_service.dart';
import '../../services/opencv_target_service.dart';
enum AnalysisState { initial, loading, success, error } enum AnalysisState { initial, loading, success, error }
@@ -26,6 +27,7 @@ class AnalysisProvider extends ChangeNotifier {
final GroupingAnalyzerService _groupingAnalyzerService; final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository; final SessionRepository _sessionRepository;
final DistortionCorrectionService _distortionService; final DistortionCorrectionService _distortionService;
final OpenCVTargetService _opencvTargetService;
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
AnalysisProvider({ AnalysisProvider({
@@ -34,11 +36,13 @@ class AnalysisProvider extends ChangeNotifier {
required GroupingAnalyzerService groupingAnalyzerService, required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository, required SessionRepository sessionRepository,
DistortionCorrectionService? distortionService, DistortionCorrectionService? distortionService,
OpenCVTargetService? opencvTargetService,
}) : _detectionService = detectionService, }) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService, _scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService, _groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository, _sessionRepository = sessionRepository,
_distortionService = distortionService ?? DistortionCorrectionService(); _distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
AnalysisState _state = AnalysisState.initial; AnalysisState _state = AnalysisState.initial;
String? _errorMessage; String? _errorMessage;
@@ -49,6 +53,7 @@ class AnalysisProvider extends ChangeNotifier {
double _targetCenterX = 0.5; double _targetCenterX = 0.5;
double _targetCenterY = 0.5; double _targetCenterY = 0.5;
double _targetRadius = 0.4; double _targetRadius = 0.4;
double _targetInnerRadius = 0.04;
int _ringCount = 10; int _ringCount = 10;
List<double>? _ringRadii; // Individual ring radii multipliers List<double>? _ringRadii; // Individual ring radii multipliers
double _imageAspectRatio = 1.0; // width / height double _imageAspectRatio = 1.0; // width / height
@@ -79,6 +84,7 @@ class AnalysisProvider extends ChangeNotifier {
double get targetCenterX => _targetCenterX; double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY; double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius; double get targetRadius => _targetRadius;
double get targetInnerRadius => _targetInnerRadius;
int get ringCount => _ringCount; int get ringCount => _ringCount;
List<double>? get ringRadii => List<double>? get ringRadii =>
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null; _ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
@@ -134,6 +140,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = 0.5; _targetCenterX = 0.5;
_targetCenterY = 0.5; _targetCenterY = 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04;
// Initialize empty shots list // Initialize empty shots list
_shots = []; _shots = [];
@@ -156,6 +163,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = result.centerX; _targetCenterX = result.centerX;
_targetCenterY = result.centerY; _targetCenterY = result.centerY;
_targetRadius = result.radius; _targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts // Create shots from detected impacts
_shots = result.impacts.map((impact) { _shots = result.impacts.map((impact) {
@@ -215,6 +223,16 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Update a shot's score manually
void updateShotScore(String shotId, int newScore) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
_shots[index] = _shots[index].copyWith(score: newScore);
_recalculateScores();
notifyListeners();
}
/// Auto-detect impacts using image processing /// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({ Future<int> autoDetectImpacts({
int darkThreshold = 80, int darkThreshold = 80,
@@ -285,6 +303,8 @@ class AnalysisProvider extends ChangeNotifier {
double param2 = 30, double param2 = 30,
int minRadius = 5, int minRadius = 5,
int maxRadius = 50, int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5, int blurSize = 5,
bool useContourDetection = true, bool useContourDetection = true,
double minCircularity = 0.6, double minCircularity = 0.6,
@@ -484,12 +504,14 @@ class AnalysisProvider extends ChangeNotifier {
void adjustTargetPosition( void adjustTargetPosition(
double centerX, double centerX,
double centerY, double centerY,
double innerRadius,
double radius, { double radius, {
int? ringCount, int? ringCount,
List<double>? ringRadii, List<double>? ringRadii,
}) { }) {
_targetCenterX = centerX; _targetCenterX = centerX;
_targetCenterY = centerY; _targetCenterY = centerY;
_targetInnerRadius = innerRadius;
_targetRadius = radius; _targetRadius = radius;
if (ringCount != null) { if (ringCount != null) {
_ringCount = ringCount; _ringCount = ringCount;
@@ -508,6 +530,43 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio =
1.0; // The corrected image is always square (side x side)
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
print('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle /// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() { void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration( _distortionParams = _distortionService.calculateDistortionFromCalibration(
@@ -665,6 +724,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = 0.5; _targetCenterX = 0.5;
_targetCenterY = 0.5; _targetCenterY = 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04;
_ringCount = 10; _ringCount = 10;
_ringRadii = null; _ringRadii = null;
_imageAspectRatio = 1.0; _imageAspectRatio = 1.0;

View File

@@ -118,8 +118,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
actions: [ actions: [
Consumer<AnalysisProvider>( Consumer<AnalysisProvider>(
builder: (context, provider, _) { builder: (context, provider, _) {
if (provider.state != AnalysisState.success) if (provider.state != AnalysisState.success) {
return const SizedBox.shrink(); return const SizedBox.shrink();
}
return IconButton( return IconButton(
icon: Icon(_isCalibrating ? Icons.check : Icons.tune), icon: Icon(_isCalibrating ? Icons.check : Icons.tune),
onPressed: () { onPressed: () {
@@ -273,6 +274,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
child: Column( child: Column(
children: [ children: [
// Ring count slider // Ring count slider
Row( Row(
children: [ children: [
@@ -298,6 +300,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
provider.adjustTargetPosition( provider.adjustTargetPosition(
provider.targetCenterX, provider.targetCenterX,
provider.targetCenterY, provider.targetCenterY,
provider.targetInnerRadius,
provider.targetRadius, provider.targetRadius,
ringCount: value.round(), ringCount: value.round(),
); );
@@ -323,59 +326,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
], ],
), ),
// Target size slider
Row(
children: [
const Icon(
Icons.zoom_out_map,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Taille:',
style: TextStyle(color: Colors.white),
),
Expanded(
child: Slider(
value: provider.targetRadius.clamp(0.05, 3.0),
min: 0.05,
max: 3.0,
label:
'${(provider.targetRadius * 100).toStringAsFixed(0)}%',
activeColor: AppTheme.warningColor,
onChanged: (value) {
provider.adjustTargetPosition(
provider.targetCenterX,
provider.targetCenterY,
value,
ringCount: provider.ringCount,
);
},
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppTheme.warningColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${(provider.targetRadius * 100).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(color: Colors.white24, height: 16), const Divider(color: Colors.white24, height: 16),
// Distortion correction row // Distortion correction row
Row( /*Row(
children: [ children: [
const Icon( const Icon(
Icons.lens_blur, Icons.lens_blur,
@@ -440,19 +393,19 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Switch( /*Switch(
value: provider.distortionCorrectionEnabled, value: provider.distortionCorrectionEnabled,
onChanged: (value) => provider onChanged: (value) => provider
.setDistortionCorrectionEnabled(value), .setDistortionCorrectionEnabled(value),
activeTrackColor: AppTheme.primaryColor activeTrackColor: AppTheme.primaryColor
.withValues(alpha: 0.5), .withValues(alpha: 0.5),
activeThumbColor: AppTheme.primaryColor, activeThumbColor: AppTheme.primaryColor,
), ),*/
], ],
), ),
], ],
], ],
), ),*/
], ],
), ),
), ),
@@ -472,6 +425,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
initialCenterX: provider.targetCenterX, initialCenterX: provider.targetCenterX,
initialCenterY: provider.targetCenterY, initialCenterY: provider.targetCenterY,
initialRadius: provider.targetRadius, initialRadius: provider.targetRadius,
initialInnerRadius: provider.targetInnerRadius,
initialRingCount: provider.ringCount, initialRingCount: provider.ringCount,
initialRingRadii: provider.ringRadii, initialRingRadii: provider.ringRadii,
targetType: provider.targetType!, targetType: provider.targetType!,
@@ -479,6 +433,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
( (
centerX, centerX,
centerY, centerY,
innerRadius,
radius, radius,
ringCount, { ringCount, {
List<double>? ringRadii, List<double>? ringRadii,
@@ -486,6 +441,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
provider.adjustTargetPosition( provider.adjustTargetPosition(
centerX, centerX,
centerY, centerY,
innerRadius,
radius, radius,
ringCount: ringCount, ringCount: ringCount,
ringRadii: ringRadii, ringRadii: ringRadii,
@@ -647,7 +603,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
boxShadow: [ boxShadow: [
if (!_isAtBottom) if (!_isAtBottom)
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.2), color: Colors.black.withValues(alpha: 0.2),
blurRadius: 6, blurRadius: 6,
offset: const Offset(0, 3), offset: const Offset(0, 3),
), ),
@@ -725,6 +681,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
// Overlay qui se transforme avec l'image // Overlay qui se transforme avec l'image
TargetOverlay( TargetOverlay(
showRings: true,
shots: provider.shots, shots: provider.shots,
targetCenterX: provider.targetCenterX, targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY, targetCenterY: provider.targetCenterY,
@@ -811,6 +768,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
// Overlay qui se transforme avec l'image // Overlay qui se transforme avec l'image
TargetOverlay( TargetOverlay(
showRings: true,
shots: provider.shots, shots: provider.shots,
targetCenterX: provider.targetCenterX, targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY, targetCenterY: provider.targetCenterY,
@@ -923,107 +881,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} }
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) { Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
return Column( return const SizedBox.shrink();
children: [
// Reference-based detection section
if (_isSelectingReferences) ...[
Card(
color: Colors.deepPurple.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.touch_app, color: Colors.deepPurple),
const SizedBox(width: 8),
Text(
'${provider.referenceImpacts.length} reference(s) selectionnee(s)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
const Text(
'Touchez l\'image pour marquer 3-4 impacts de reference. '
'L\'algorithme apprendra leurs caracteristiques pour detecter les autres.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() => _isSelectingReferences = false);
provider.clearReferenceImpacts();
},
child: const Text('Annuler'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: provider.referenceImpacts.length >= 2
? () => _showCalibratedDetectionDialog(
context,
provider,
)
: null,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Detecter'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
),
const SizedBox(height: 12),
] else ...[
// désactiver le temps de l'amelioration du scripts d'auto-detection
// Auto-detect buttons row
// Row(
// children: [
// Expanded(
// child: ElevatedButton.icon(
// onPressed: () => _showAutoDetectDialog(context, provider),
// icon: const Icon(Icons.auto_fix_high),
// label: const Text('Auto-Detection'),
// style: ElevatedButton.styleFrom(
// backgroundColor: AppTheme.primaryColor,
// foregroundColor: Colors.white,
// padding: const EdgeInsets.symmetric(vertical: 12),
// ),
// ),
// ),
// const SizedBox(width: 12),
// Expanded(
// child: ElevatedButton.icon(
// onPressed: () => setState(() => _isSelectingReferences = true),
// icon: const Icon(Icons.touch_app),
// label: const Text('Par Reference'),
// style: ElevatedButton.styleFrom(
// backgroundColor: Colors.deepPurple,
// foregroundColor: Colors.white,
// padding: const EdgeInsets.symmetric(vertical: 12),
// ),
// ),
// ),
// ],
// ),
const SizedBox(height: 12),
],
// Manual actions
],
);
} }
void _showHelpDialog(BuildContext context) { void _showHelpDialog(BuildContext context) {
@@ -1061,25 +919,73 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
AnalysisProvider provider, AnalysisProvider provider,
String shotId, String shotId,
) { ) {
final shot = provider.shots.firstWhere((s) => s.id == shotId);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea( builder: (context) => SafeArea(
child: Wrap( child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( Text(
leading: const Icon(Icons.delete, color: AppTheme.errorColor), 'Modifier l\'impact',
title: const Text('Supprimer cet impact'), style: Theme.of(context).textTheme.titleLarge,
onTap: () { ),
provider.removeShot(shotId); const SizedBox(height: 20),
// Dropdown for score
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Valeur de l\'impact : ', style: TextStyle(fontSize: 16)),
const SizedBox(width: 10),
DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (i) => i).map((i) {
return DropdownMenuItem<int>(
value: i,
child: Text(i == 10 && provider.targetType == TargetType.concentric ? '10 (X)' : '$i'),
);
}).toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shotId, newScore);
Navigator.pop(context); Navigator.pop(context);
}
}, },
), ),
], ],
), ),
const SizedBox(height: 20),
// Delete button at the bottom
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
provider.removeShot(shotId);
Navigator.pop(context);
},
icon: const Icon(Icons.delete),
label: const Text('SUPPRIMER L\'IMPACT'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
), ),
); );
} }
/*
void _showAddShotHint(BuildContext context) { void _showAddShotHint(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -1088,6 +994,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
); );
} }
*/
void _showClearConfirmation(BuildContext context, AnalysisProvider provider) { void _showClearConfirmation(BuildContext context, AnalysisProvider provider) {
showDialog( showDialog(
@@ -1117,6 +1024,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
); );
} }
void _showAutoDetectDialog(BuildContext context, AnalysisProvider provider) { void _showAutoDetectDialog(BuildContext context, AnalysisProvider provider) {
// Detection settings // Detection settings
bool clearExisting = true; bool clearExisting = true;
@@ -1126,9 +1034,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
int maxImpactSize = 500; int maxImpactSize = 500;
double minFillRatio = 0.5; double minFillRatio = 0.5;
// NOTE: OpenCV désactivé - problèmes de build Windows
// Utilisation de la détection classique uniquement
showDialog( showDialog(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
@@ -1145,11 +1050,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'Ajustez les parametres de detection:', 'Détection Classique (Paramétrable):',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
// Dark threshold slider // Dark threshold slider
Text('Seuil de detection (zones sombres): $darkThreshold'), Text('Seuil de detection (zones sombres): $darkThreshold'),
@@ -1308,7 +1214,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} }
}, },
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
label: const Text('Detecter'), label: const Text('Détecter (Classique)'),
), ),
], ],
), ),

View File

@@ -13,16 +13,26 @@ class TargetCalibration extends StatefulWidget {
final double initialCenterX; final double initialCenterX;
final double initialCenterY; final double initialCenterY;
final double initialRadius; final double initialRadius;
final double initialInnerRadius;
final int initialRingCount; final int initialRingCount;
final TargetType targetType; final TargetType targetType;
final List<double>? initialRingRadii; final List<double>? initialRingRadii;
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? ringRadii}) onCalibrationChanged; final Function(
double centerX,
double centerY,
double innerRadius,
double radius,
int ringCount, {
List<double>? ringRadii,
})
onCalibrationChanged;
const TargetCalibration({ const TargetCalibration({
super.key, super.key,
required this.initialCenterX, required this.initialCenterX,
required this.initialCenterY, required this.initialCenterY,
required this.initialRadius, required this.initialRadius,
required this.initialInnerRadius,
this.initialRingCount = 10, this.initialRingCount = 10,
required this.targetType, required this.targetType,
this.initialRingRadii, this.initialRingRadii,
@@ -37,11 +47,13 @@ class _TargetCalibrationState extends State<TargetCalibration> {
late double _centerX; late double _centerX;
late double _centerY; late double _centerY;
late double _radius; late double _radius;
late double _innerRadius;
late int _ringCount; late int _ringCount;
late List<double> _ringRadii; late List<double> _ringRadii;
bool _isDraggingCenter = false; bool _isDraggingCenter = false;
bool _isDraggingRadius = false; bool _isDraggingRadius = false;
bool _isDraggingInnerRadius = false;
@override @override
void initState() { void initState() {
@@ -49,28 +61,57 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_centerX = widget.initialCenterX; _centerX = widget.initialCenterX;
_centerY = widget.initialCenterY; _centerY = widget.initialCenterY;
_radius = widget.initialRadius; _radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); _initRingRadii();
} }
void _initRingRadii() { void _initRingRadii() {
if (widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) { if (widget.initialRingRadii != null &&
widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!); _ringRadii = List.from(widget.initialRingRadii!);
} else { } else {
// Initialize with default proportional radii // Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount); _ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius;
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
});
} }
} }
@override @override
void didUpdateWidget(TargetCalibration oldWidget) { void didUpdateWidget(TargetCalibration oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX &&
!_isDraggingCenter) {
_centerX = widget.initialCenterX;
}
if (widget.initialCenterY != oldWidget.initialCenterY &&
!_isDraggingCenter) {
_centerY = widget.initialCenterY;
}
if (widget.initialRingCount != oldWidget.initialRingCount) { if (widget.initialRingCount != oldWidget.initialRingCount) {
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); shouldReinit = true;
} }
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) { if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) {
_radius = widget.initialRadius; _radius = widget.initialRadius;
shouldReinit = true;
}
if (widget.initialInnerRadius != oldWidget.initialInnerRadius &&
!_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii) {
shouldReinit = true;
}
if (shouldReinit) {
_initRingRadii();
} }
} }
@@ -90,11 +131,13 @@ class _TargetCalibrationState extends State<TargetCalibration> {
centerX: _centerX, centerX: _centerX,
centerY: _centerY, centerY: _centerY,
radius: _radius, radius: _radius,
innerRadius: _innerRadius,
ringCount: _ringCount, ringCount: _ringCount,
ringRadii: _ringRadii, ringRadii: _ringRadii,
targetType: widget.targetType, targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter, isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius, isDraggingRadius: _isDraggingRadius,
isDraggingInnerRadius: _isDraggingInnerRadius,
), ),
), ),
); );
@@ -109,21 +152,42 @@ class _TargetCalibrationState extends State<TargetCalibration> {
// Check if tapping on center handle // Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY); final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
// Check if tapping on radius handle (on the right edge of the outermost circle) // Check if tapping on outer radius handle
final minDim = math.min(size.width, size.height); final minDim = math.min(size.width, size.height);
final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0); final outerRadius = _radius;
final radiusHandleX = _centerX + outerRadius * minDim / size.width; final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY; final radiusHandleY = _centerY;
final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0)); final distToOuterHandle = _distance(
tapX,
tapY,
radiusHandleX.clamp(0.0, 1.0),
radiusHandleY.clamp(0.0, 1.0),
);
// Check if tapping on inner radius handle (top edge of innermost circle)
final actualInnerRadius = _innerRadius;
final innerHandleX = _centerX;
final innerHandleY = _centerY - actualInnerRadius * minDim / size.height;
final distToInnerHandle = _distance(
tapX,
tapY,
innerHandleX.clamp(0.0, 1.0),
innerHandleY.clamp(0.0, 1.0),
);
// Increase touch target size slightly for handles
if (distToCenter < 0.05) { if (distToCenter < 0.05) {
setState(() { setState(() {
_isDraggingCenter = true; _isDraggingCenter = true;
}); });
} else if (distToRadiusHandle < 0.05) { } else if (distToOuterHandle < 0.05) {
setState(() { setState(() {
_isDraggingRadius = true; _isDraggingRadius = true;
}); });
} else if (distToInnerHandle < 0.05) {
setState(() {
_isDraggingInnerRadius = true;
});
} else if (distToCenter < _radius + 0.02) { } else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center // Tapping inside the target - move center
setState(() { setState(() {
@@ -143,19 +207,36 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_centerX = _centerX + deltaX; _centerX = _centerX + deltaX;
_centerY = _centerY + deltaY; _centerY = _centerY + deltaY;
} else if (_isDraggingRadius) { } else if (_isDraggingRadius) {
// Adjust outer radius (scales all rings proportionally) // Adjust outer radius
final newRadius = _radius + deltaX * (size.width / minDim); final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(0.05, 3.0); _radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0);
_initRingRadii(); // Recalculate linear separation
} else if (_isDraggingInnerRadius) {
// Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY)
final newInnerRadius = _innerRadius - deltaY * (size.height / minDim);
_innerRadius = newInnerRadius.clamp(
0.01,
math.max(0.01, _radius - 0.01),
);
_initRingRadii(); // Recalculate linear separation
} }
}); });
widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii); widget.onCalibrationChanged(
_centerX,
_centerY,
_innerRadius,
_radius,
_ringCount,
ringRadii: _ringRadii,
);
} }
void _onPanEnd() { void _onPanEnd() {
setState(() { setState(() {
_isDraggingCenter = false; _isDraggingCenter = false;
_isDraggingRadius = false; _isDraggingRadius = false;
_isDraggingInnerRadius = false;
}); });
} }
@@ -170,21 +251,25 @@ class _CalibrationPainter extends CustomPainter {
final double centerX; final double centerX;
final double centerY; final double centerY;
final double radius; final double radius;
final double innerRadius;
final int ringCount; final int ringCount;
final List<double> ringRadii; final List<double> ringRadii;
final TargetType targetType; final TargetType targetType;
final bool isDraggingCenter; final bool isDraggingCenter;
final bool isDraggingRadius; final bool isDraggingRadius;
final bool isDraggingInnerRadius;
_CalibrationPainter({ _CalibrationPainter({
required this.centerX, required this.centerX,
required this.centerY, required this.centerY,
required this.radius, required this.radius,
required this.innerRadius,
required this.ringCount, required this.ringCount,
required this.ringRadii, required this.ringRadii,
required this.targetType, required this.targetType,
required this.isDraggingCenter, required this.isDraggingCenter,
required this.isDraggingRadius, required this.isDraggingRadius,
required this.isDraggingInnerRadius,
}); });
@override @override
@@ -192,6 +277,7 @@ class _CalibrationPainter extends CustomPainter {
final centerPx = Offset(centerX * size.width, centerY * size.height); final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height; final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim; final baseRadiusPx = radius * minDim;
final innerRadiusPx = innerRadius * minDim;
if (targetType == TargetType.concentric) { if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx); _drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
@@ -199,17 +285,42 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx); _drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
} }
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) {
final crosshairLinePaint = Paint()
..color = AppTheme.successColor.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 // Draw center handle
_drawCenterHandle(canvas, centerPx); _drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring) // Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx); _drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw inner radius handle
_drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx);
// Draw instructions // Draw instructions
_drawInstructions(canvas, size); _drawInstructions(canvas, size);
} }
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) { void _drawConcentricZones(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones // Generate colors for zones
List<Color> zoneColors = []; List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
@@ -235,7 +346,9 @@ class _CalibrationPainter extends CustomPainter {
// Draw from outside to inside // Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) { 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; final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i]; zonePaint.color = zoneColors[i];
@@ -244,12 +357,12 @@ class _CalibrationPainter extends CustomPainter {
} }
// Draw zone labels (only if within visible area) // Draw zone labels (only if within visible area)
final textPainter = TextPainter( final textPainter = TextPainter(textDirection: TextDirection.ltr);
textDirection: TextDirection.ltr,
);
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final prevRingRadius = i > 0 final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0; : 0.0;
@@ -268,9 +381,7 @@ class _CalibrationPainter extends CustomPainter {
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.9),
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
shadows: const [ shadows: const [Shadow(color: Colors.black, blurRadius: 2)],
Shadow(color: Colors.black, blurRadius: 2),
],
), ),
); );
textPainter.layout(); textPainter.layout();
@@ -278,14 +389,24 @@ class _CalibrationPainter extends CustomPainter {
// Draw label on the right side of each zone // Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2; final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) { 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) { void _drawSilhouetteZones(
Canvas canvas,
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones // Simplified silhouette zones
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2; final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now) // Draw silhouette outline (simplified as rectangle for now)
final silhouetteWidth = radius * 0.8; final silhouetteWidth = radius * 0.8;
@@ -293,7 +414,11 @@ class _CalibrationPainter extends CustomPainter {
paint.color = Colors.green.withValues(alpha: 0.5); paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect( canvas.drawRect(
Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint, paint,
); );
} }
@@ -316,17 +441,36 @@ class _CalibrationPainter extends CustomPainter {
final crossPaint = Paint() final crossPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..strokeWidth = 2; ..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint); canvas.drawLine(
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint); Offset(center.dx - 20, center.dy),
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint); Offset(center.dx - 8, center.dy),
canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint); 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,
);
} }
void _drawRadiusHandle(Canvas canvas, Size size, Offset center, double baseRadius) { void _drawRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Radius handle on the right edge of the outermost ring // Radius handle on the right edge of the outermost ring
final outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0; final actualHandleX = center.dx + baseRadius;
final actualRadius = baseRadius * outerRingRadius;
final actualHandleX = center.dx + actualRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20); final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20); final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY); final handlePos = Offset(clampedHandleX, clampedHandleY);
@@ -376,7 +520,7 @@ class _CalibrationPainter extends CustomPainter {
// Label // Label
final textPainter = TextPainter( final textPainter = TextPainter(
text: const TextSpan( text: const TextSpan(
text: 'RAYON', text: 'EXT.',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 8, fontSize: 8,
@@ -392,6 +536,78 @@ class _CalibrationPainter extends CustomPainter {
); );
} }
void _drawInnerRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double innerRadiusPx,
) {
// Inner radius handle on the top edge of the innermost ring
final actualHandleY = center.dy - innerRadiusPx;
final clampedHandleX = center.dx.clamp(20.0, size.width - 20);
final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
final isClamped = actualHandleY < 20.0;
final paint = Paint()
..color = isDraggingInnerRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : Colors.purpleAccent)
..style = PaintingStyle.fill;
// Draw handle
canvas.drawCircle(handlePos, 14, paint);
// Up/Down arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Up arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx - 4, handlePos.dy - 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx + 4, handlePos.dy - 8),
arrowPaint,
);
// Down arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx - 4, handlePos.dy + 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx + 4, handlePos.dy + 8),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'INT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy - 24),
);
}
void _drawInstructions(Canvas canvas, Size size) { void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon'; const instruction = 'Deplacez le centre ou ajustez le rayon';
@@ -418,9 +634,11 @@ class _CalibrationPainter extends CustomPainter {
return centerX != oldDelegate.centerX || return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY || centerY != oldDelegate.centerY ||
radius != oldDelegate.radius || radius != oldDelegate.radius ||
innerRadius != oldDelegate.innerRadius ||
ringCount != oldDelegate.ringCount || ringCount != oldDelegate.ringCount ||
isDraggingCenter != oldDelegate.isDraggingCenter || isDraggingCenter != oldDelegate.isDraggingCenter ||
isDraggingRadius != oldDelegate.isDraggingRadius || isDraggingRadius != oldDelegate.isDraggingRadius ||
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
ringRadii != oldDelegate.ringRadii; ringRadii != oldDelegate.ringRadii;
} }
} }

View File

@@ -25,6 +25,7 @@ class TargetOverlay extends StatelessWidget {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
const TargetOverlay({ const TargetOverlay({
super.key, super.key,
@@ -42,6 +43,7 @@ class TargetOverlay extends StatelessWidget {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
@@ -72,6 +74,7 @@ class TargetOverlay extends StatelessWidget {
groupingDiameter: groupingDiameter, groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts, referenceImpacts: referenceImpacts,
zoomScale: zoomScale, zoomScale: zoomScale,
showRings: showRings,
), ),
child: Stack( child: Stack(
children: shots.map((shot) { children: shots.map((shot) {
@@ -132,6 +135,7 @@ class _TargetOverlayPainter extends CustomPainter {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
_TargetOverlayPainter({ _TargetOverlayPainter({
required this.shots, required this.shots,
@@ -146,12 +150,15 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
// Draw target center indicator // Draw target center indicator
if (showRings) {
_drawTargetCenter(canvas, size); _drawTargetCenter(canvas, size);
}
// Draw grouping circle // Draw grouping circle
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) { if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
@@ -371,6 +378,7 @@ class _TargetOverlayPainter extends CustomPainter {
groupingCenterY != oldDelegate.groupingCenterY || groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter || groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts || referenceImpacts != oldDelegate.referenceImpacts ||
zoomScale != oldDelegate.zoomScale; zoomScale != oldDelegate.zoomScale ||
showRings != oldDelegate.showRings;
} }
} }

View File

@@ -1,18 +1,14 @@
/// Écran de capture - Première étape du workflow d'analyse.
///
/// Permet de sélectionner le type de cible (concentrique ou silhouette)
/// et la source d'image (caméra ou galerie). Affiche un aperçu de l'image
/// sélectionnée avant de lancer l'analyse.
library;
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.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 '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart'; import '../crop/crop_screen.dart';
import 'widgets/target_type_selector.dart';
import 'widgets/image_source_button.dart'; import 'widgets/image_source_button.dart';
class CaptureScreen extends StatefulWidget { class CaptureScreen extends StatefulWidget {
@@ -24,34 +20,75 @@ class CaptureScreen extends StatefulWidget {
class _CaptureScreenState extends State<CaptureScreen> { class _CaptureScreenState extends State<CaptureScreen> {
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
TargetType _selectedType = TargetType.concentric; final TargetType _selectedType = TargetType.concentric;
String? _selectedImagePath; String? _selectedImagePath;
bool _isLoading = false; bool _isLoading = false;
/// Gère la demande de permission et la sélection d'image depuis la galerie
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();
}
} else {
status = await Permission.photos.request();
}
if (status.isGranted) {
_captureImage(ImageSource.gallery);
} else if (status.isPermanentlyDenied) {
_showSettingsDialog();
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Accès à la galerie requis pour continuer'),
),
);
}
}
}
void _showSettingsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission requise'),
content: const Text(
'L\'accès aux photos est nécessaire. Veuillez l\'activer dans les paramètres.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => openAppSettings(),
child: const Text('Paramètres'),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Source')),
title: const Text('Nouvelle Analyse'),
),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// TODO: une fois la cible de silhouette mise en place, rajouter le selecteur
// Target type selection
// _buildSectionTitle('Type de Cible'),
// const SizedBox(height: 12),
// TargetTypeSelector(
// selectedType: _selectedType,
// onTypeSelected: (type) {
// setState(() => _selectedType = type);
// },
// ),
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
// Image source selection
_buildSectionTitle('Source de l\'Image'), _buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
@@ -59,8 +96,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
Expanded( Expanded(
child: ImageSourceButton( child: ImageSourceButton(
icon: Icons.camera_alt, icon: Icons.camera_alt,
label: 'Camera', label: 'Scanner',
onPressed: _isLoading ? null : () => _captureImage(ImageSource.camera), onPressed: _isLoading ? null : _scanDocument,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -68,14 +105,13 @@ class _CaptureScreenState extends State<CaptureScreen> {
child: ImageSourceButton( child: ImageSourceButton(
icon: Icons.photo_library, icon: Icons.photo_library,
label: 'Galerie', label: 'Galerie',
onPressed: _isLoading ? null : () => _captureImage(ImageSource.gallery), onPressed: _isLoading ? null : _handleGallerySelection,
), ),
), ),
], ],
), ),
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
// Image preview
if (_isLoading) if (_isLoading)
const Center( const Center(
child: Padding( child: Padding(
@@ -84,37 +120,60 @@ class _CaptureScreenState extends State<CaptureScreen> {
), ),
) )
else if (_selectedImagePath != null) else if (_selectedImagePath != null)
_buildImagePreview(), _buildImagePreview()
else
// Guide text
if (_selectedImagePath == null && !_isLoading)
_buildGuide(), _buildGuide(),
// --- NOUVEAU BOUTON VALIDER ---
if (_selectedImagePath != null && !_isLoading) ...[
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF112233), // Ton bleu foncé
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 54),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
onPressed: _analyzeImage,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Valider',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
SizedBox(width: 12),
Icon(Icons.arrow_forward, size: 20),
],
),
),
const SizedBox(height: 32),
],
], ],
), ),
), ),
floatingActionButton: _selectedImagePath != null
? FloatingActionButton.extended(
onPressed: _analyzeImage,
icon: const Icon(Icons.analytics),
label: const Text('Analyser'),
)
: null,
); );
} }
Widget _buildSectionTitle(String title) { Widget _buildSectionTitle(String title) {
return Text( return Text(
title, title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
); );
} }
Widget _buildImagePreview() { Widget _buildImagePreview() {
return Column( return Column(
children: [ children: [
_buildSectionTitle('Apercu'), _buildSectionTitle('Aperçu'),
const SizedBox(height: 12), const SizedBox(height: 12),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(AppConstants.borderRadius), borderRadius: BorderRadius.circular(AppConstants.borderRadius),
@@ -130,9 +189,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
right: 8, right: 8,
child: IconButton( child: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () { onPressed: () => setState(() => _selectedImagePath = null),
setState(() => _selectedImagePath = null);
},
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -150,7 +207,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
Widget _buildFramingHints() { Widget _buildFramingHints() {
return Card( return Card(
color: AppTheme.warningColor.withValues(alpha: 0.1), color: AppTheme.warningColor.withOpacity(0.1),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row( child: Row(
@@ -159,8 +216,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Assurez-vous que la cible est bien centree et visible.', 'Assurez-vous que la cible est bien centrée et visible.',
style: TextStyle(color: AppTheme.warningColor.withValues(alpha: 0.8)), style: TextStyle(color: AppTheme.warningColor.withOpacity(0.8)),
), ),
), ),
], ],
@@ -175,23 +232,26 @@ class _CaptureScreenState extends State<CaptureScreen> {
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
children: [ children: [
Icon( Icon(Icons.help_outline, size: 48, color: Colors.grey[400]),
Icons.help_outline,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Conseils pour une bonne analyse', 'Conseils pour une bonne analyse',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildGuideItem(Icons.crop_free, 'Cadrez la cible entiere dans l\'image'), _buildGuideItem(
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon eclairage'), Icons.crop_free,
'Cadrez la cible entière dans l\'image',
),
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon éclairage'),
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'), _buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
_buildGuideItem(Icons.blur_off, 'Evitez les images floues'), _buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
_buildGuideItem(
Icons.cleaning_services,
'Nettoyer votre objectif avec une chiffonnette',
),
], ],
), ),
), ),
@@ -211,9 +271,29 @@ 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) {
setState(() => _selectedImagePath = documents.images.first);
}
} catch (e) {
debugPrint('Erreur scan: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _captureImage(ImageSource source) async { Future<void> _captureImage(ImageSource source) async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final XFile? image = await _picker.pickImage( final XFile? image = await _picker.pickImage(
source: source, source: source,
@@ -221,29 +301,18 @@ class _CaptureScreenState extends State<CaptureScreen> {
maxHeight: 2048, maxHeight: 2048,
imageQuality: 90, imageQuality: 90,
); );
if (image != null) { if (image != null) {
setState(() => _selectedImagePath = image.path); setState(() => _selectedImagePath = image.path);
} }
} catch (e) { } catch (e) {
if (mounted) { debugPrint('Erreur capture: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la capture: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} finally { } finally {
if (mounted) { if (mounted) setState(() => _isLoading = false);
setState(() => _isLoading = false);
}
} }
} }
void _analyzeImage() { void _analyzeImage() {
if (_selectedImagePath == null) return; if (_selectedImagePath == null) return;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(

View File

@@ -1,18 +1,9 @@
/// Écran de recadrage d'image en format carré (1:1).
///
/// Permet à l'utilisateur de déplacer et zoomer l'image pour sélectionner
/// la zone à recadrer. Le carré de recadrage est fixe au centre de l'écran.
library;
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
// Importations ajustées selon ton arborescence à gauche
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
import '../../services/image_crop_service.dart';
import '../analysis/analysis_screen.dart'; import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget { class CropScreen extends StatefulWidget {
final String imagePath; final String imagePath;
@@ -29,315 +20,105 @@ class CropScreen extends StatefulWidget {
} }
class _CropScreenState extends State<CropScreen> { class _CropScreenState extends State<CropScreen> {
final ImageCropService _cropService = ImageCropService();
bool _isLoading = false;
bool _imageLoaded = false;
Size? _imageSize;
// Position et échelle de l'image
Offset _offset = Offset.zero;
double _scale = 1.0;
double _baseScale = 1.0;
Offset _startFocalPoint = Offset.zero;
Offset _startOffset = Offset.zero;
// Dimensions calculées
double _cropSize = 0;
Size _viewportSize = Size.zero;
@override
void initState() {
super.initState();
_loadImageDimensions();
}
Future<void> _loadImageDimensions() async {
final file = File(widget.imagePath);
final bytes = await file.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
setState(() {
_imageSize = Size(
frame.image.width.toDouble(),
frame.image.height.toDouble(),
);
_imageLoaded = true;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: const Color(0xFF101214),
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.black, backgroundColor: const Color(0xFF101214),
foregroundColor: Colors.white, elevation: 0,
title: const Text('Recadrer'), centerTitle: true,
title: const Text(
'Validation image',
style: TextStyle(color: Colors.white, fontSize: 18),
),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
actions: [
if (!_isLoading)
IconButton(
icon: const Icon(Icons.check),
onPressed: _onCropConfirm,
), ),
], body: Column(
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (!_imageLoaded || _imageSize == null) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const CircularProgressIndicator(color: Colors.white), // L'image capturée
const SizedBox(height: 16), Expanded(
Text( child: Container(
'Recadrage en cours...', margin: const EdgeInsets.all(20),
style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
), ),
], child: ClipRRect(
), borderRadius: BorderRadius.circular(12),
);
}
return LayoutBuilder(
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// Taille du carré de crop (90% de la plus petite dimension)
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.85;
// Calculer l'échelle initiale si pas encore fait
if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition();
}
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
child: Stack(
children: [
// Image transformée
Positioned.fill(
child: Center(
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale, 1.0),
alignment: Alignment.center,
child: Image.file( child: Image.file(
File(widget.imagePath), File(widget.imagePath),
fit: BoxFit.contain, fit: BoxFit.contain,
width: _viewportSize.width,
height: _viewportSize.height,
), ),
), ),
), ),
), ),
// Overlay de recadrage // Bouton Modifier
Positioned.fill( Padding(
child: IgnorePointer( padding: const EdgeInsets.symmetric(vertical: 20),
child: CropOverlay( child: ElevatedButton.icon(
cropSize: _cropSize, onPressed: () {
showGrid: true, // Logique de modification à venir
),
),
),
// Instructions en bas
Positioned(
left: 0,
right: 0,
bottom: 24,
child: _buildInstructions(),
),
],
),
);
}, },
); icon: const Icon(Icons.crop_rotate),
} label: const Text('Modifier'),
style: ElevatedButton.styleFrom(
void _initializeImagePosition() { backgroundColor: const Color(0xFF1A73E8),
if (_imageSize == null) return; foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
final imageAspect = _imageSize!.width / _imageSize!.height; shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
final viewportAspect = _viewportSize.width / _viewportSize.height;
// Calculer la taille de l'image affichée (avec BoxFit.contain)
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// Échelle pour que le plus petit côté de l'image remplisse le carré de crop
final minDisplayDim = math.min(displayWidth, displayHeight);
_scale = _cropSize / minDisplayDim;
// S'assurer d'un scale minimum
if (_scale < 1.0) _scale = 1.0;
}
void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
_startFocalPoint = details.focalPoint;
_startOffset = _offset;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
// Mise à jour du scale
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
// Mise à jour de la position
final delta = details.focalPoint - _startFocalPoint;
_offset = _startOffset + delta;
});
}
void _onScaleEnd(ScaleEndDetails details) {
// Optionnel: contraindre l'image pour qu'elle couvre toujours le carré de crop
}
Widget _buildInstructions() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
), ),
),
),
// Boutons du bas
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Expanded(
Icons.touch_app, child: OutlinedButton(
color: Colors.white.withValues(alpha: 0.8), onPressed: () => Navigator.pop(context),
size: 20, style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
), ),
const SizedBox(width: 8), child: const Text('Recommencer', style: TextStyle(color: Colors.white)),
Text(
'Déplacez et zoomez pour cadrer la cible',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
), ),
), ),
], const SizedBox(width: 16),
), Expanded(
); child: ElevatedButton(
} onPressed: () {
Navigator.push(
Future<void> _onCropConfirm() async {
if (_imageSize == null) return;
setState(() {
_isLoading = true;
});
try {
// Calculer la zone de crop en coordonnées normalisées de l'image
final cropRect = _calculateCropRect();
// Recadrer l'image
final croppedPath = await _cropService.cropToSquare(
widget.imagePath,
cropRect,
);
if (!mounted) return;
// Naviguer vers l'écran d'analyse avec l'image recadrée
Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AnalysisScreen( builder: (_) => AnalysisScreen(
imagePath: croppedPath, imagePath: widget.imagePath,
targetType: widget.targetType, targetType: widget.targetType,
), ),
), ),
); );
} catch (e) { },
if (!mounted) return; style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
setState(() { foregroundColor: Colors.white,
_isLoading = false; padding: const EdgeInsets.symmetric(vertical: 15),
}); shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
ScaffoldMessenger.of(context).showSnackBar( child: const Text('Valider', style: TextStyle(fontWeight: FontWeight.bold)),
SnackBar( ),
content: Text('Erreur lors du recadrage: $e'), ),
backgroundColor: AppTheme.errorColor, ],
),
),
],
), ),
); );
} }
} }
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;
// Calculer la taille de l'image affichée (avec BoxFit.contain)
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// Taille de l'image après scale
final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale;
// Position du centre de l'image dans le viewport
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
// Position du coin supérieur gauche de l'image
final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2;
// Position du carré de crop (centré dans le viewport)
final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2;
// Convertir en coordonnées relatives à l'image affichée
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropSize = _cropSize / scaledWidth;
final relCropSizeY = _cropSize / scaledHeight;
return CropRect(
x: relCropLeft.clamp(0.0, 1.0),
y: relCropTop.clamp(0.0, 1.0),
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)),
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)),
);
}
}

View File

@@ -13,20 +13,13 @@ class CropOverlay extends StatelessWidget {
/// Afficher la grille des tiers /// Afficher la grille des tiers
final bool showGrid; final bool showGrid;
const CropOverlay({ const CropOverlay({super.key, required this.cropSize, this.showGrid = true});
super.key,
required this.cropSize,
this.showGrid = true,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomPaint( return CustomPaint(
size: Size.infinite, size: Size.infinite,
painter: _CropOverlayPainter( painter: _CropOverlayPainter(cropSize: cropSize, showGrid: showGrid),
cropSize: cropSize,
showGrid: showGrid,
),
); );
} }
} }
@@ -35,10 +28,7 @@ class _CropOverlayPainter extends CustomPainter {
final double cropSize; final double cropSize;
final bool showGrid; final bool showGrid;
_CropOverlayPainter({ _CropOverlayPainter({required this.cropSize, required this.showGrid});
required this.cropSize,
required this.showGrid,
});
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
@@ -77,6 +67,9 @@ class _CropOverlayPainter extends CustomPainter {
if (showGrid) { if (showGrid) {
_drawGrid(canvas, cropRect); _drawGrid(canvas, cropRect);
} }
// Dessiner le point central (croix)
_drawCenterPoint(canvas, cropRect);
} }
void _drawCorners(Canvas canvas, Rect rect) { void _drawCorners(Canvas canvas, Rect rect) {
@@ -171,6 +164,38 @@ class _CropOverlayPainter extends CustomPainter {
); );
} }
void _drawCenterPoint(Canvas canvas, Rect rect) {
final centerPaint = Paint()
..color = Colors.white.withValues(alpha: 0.8)
..style = PaintingStyle.stroke
..strokeWidth = 2;
const size = 10.0;
final centerX = rect.center.dx;
final centerY = rect.center.dy;
// Ligne horizontale
canvas.drawLine(
Offset(centerX - size, centerY),
Offset(centerX + size, centerY),
centerPaint,
);
// Ligne verticale
canvas.drawLine(
Offset(centerX, centerY - size),
Offset(centerX, centerY + size),
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 @override
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) { bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid; return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;

View File

@@ -1,10 +1,3 @@
/// Écran d'historique des sessions.
///
/// Affiche la liste des sessions passées avec filtrage par type de cible.
/// Inclut un graphique d'évolution des scores (10 dernières sessions)
/// et permet la navigation vers le détail de chaque session.
library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -29,6 +22,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
bool _isLoading = true; bool _isLoading = true;
TargetType? _filterType; TargetType? _filterType;
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
DateTimeRange? _selectedDateRange;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -36,17 +32,43 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
Future<void> _loadSessions() async { Future<void> _loadSessions() async {
if (!mounted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final sessions = await repository.getAllSessions( final sessions = await repository.getAllSessions(targetType: _filterType);
targetType: _filterType,
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_sessions = sessions; _sessions = sessions;
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) {
_sessions = _sessions.where((s) {
// On compare uniquement les dates (sans les heures) pour éviter les bugs
final sessionDate = DateTime(
s.createdAt.year,
s.createdAt.month,
s.createdAt.day,
);
final startDate = DateTime(
_selectedDateRange!.start.year,
_selectedDateRange!.start.month,
_selectedDateRange!.start.day,
);
final endDate = DateTime(
_selectedDateRange!.end.year,
_selectedDateRange!.end.month,
_selectedDateRange!.end.day,
);
return sessionDate.isAtSameMomentAs(startDate) ||
sessionDate.isAtSameMomentAs(endDate) ||
(sessionDate.isAfter(startDate) &&
sessionDate.isBefore(endDate));
}).toList();
}
_isLoading = false; _isLoading = false;
}); });
} }
@@ -55,7 +77,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() => _isLoading = false); setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Erreur de chargement: $e'), content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor, backgroundColor: AppTheme.errorColor,
), ),
); );
@@ -63,6 +85,35 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
} }
// --- MODIFICATION : Fonction DateRangePicker ---
Future<void> _pickDateRange() async {
final DateTimeRange? picked = await showDateRangePicker(
context: context,
initialDateRange: _selectedDateRange,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.dark(
primary: AppTheme.primaryColor,
onPrimary: Colors.white,
surface: Theme.of(context).cardColor,
onSurface: Colors.white,
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() => _selectedDateRange = picked);
_loadSessions();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -71,56 +122,87 @@ class _HistoryScreenState extends State<HistoryScreen> {
actions: [ actions: [
PopupMenuButton<TargetType?>( PopupMenuButton<TargetType?>(
icon: const Icon(Icons.filter_list), icon: const Icon(Icons.filter_list),
tooltip: 'Filtrer',
onSelected: (type) { onSelected: (type) {
setState(() => _filterType = type); setState(() => _filterType = type);
_loadSessions(); _loadSessions();
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem( const PopupMenuItem(value: null, child: Text('Tous')),
value: null, ...TargetType.values.map(
child: Text('Tous'), (type) =>
PopupMenuItem(value: type, child: Text(type.displayName)),
), ),
...TargetType.values.map((type) => PopupMenuItem(
value: type,
child: Text(type.displayName),
)),
], ],
), ),
], ],
), ),
body: _isLoading body: Column(
children: [
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty : _sessions.isEmpty
? _buildEmptyState() ? _buildEmptyState()
: _buildContent(), : _buildContent(),
),
_buildBottomFilterBar(),
],
),
);
}
Widget _buildBottomFilterBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickDateRange,
icon: const Icon(Icons.date_range, size: 18),
label: Text(
_selectedDateRange == null
? 'Choisir une période'
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
),
),
),
if (_selectedDateRange != null)
IconButton(
icon: const Icon(Icons.close, color: AppTheme.errorColor),
onPressed: () {
setState(() => _selectedDateRange = null);
_loadSessions();
},
),
],
),
),
); );
} }
Widget _buildEmptyState() { Widget _buildEmptyState() {
return Center( return Center(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]), Icon(Icons.history, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( const Text('Aucune session sur cette période'),
'Aucune session',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_filterType != null
? 'Aucune session de type ${_filterType!.displayName}'
: 'Commencez par analyser une cible',
style: TextStyle(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
], ],
), ),
),
); );
} }
@@ -129,37 +211,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
onRefresh: _loadSessions, onRefresh: _loadSessions,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
// Chart section if (_sessions.length >= 2 && _selectedDateRange == null)
if (_sessions.length >= 2)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: HistoryChart(sessions: _sessions), child: HistoryChart(sessions: _sessions),
), ),
), ),
// Filter indicator
if (_filterType != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppConstants.defaultPadding),
child: Chip(
label: Text('Filtre: ${_filterType!.displayName}'),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () {
setState(() => _filterType = null);
_loadSessions();
},
),
),
),
// Sessions list
SliverPadding( SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) {
final session = _sessions[index]; final session = _sessions[index];
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -169,9 +231,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
onDelete: () => _deleteSession(session), onDelete: () => _deleteSession(session),
), ),
); );
}, }, childCount: _sessions.length),
childCount: _sessions.length,
),
), ),
), ),
], ],
@@ -182,11 +242,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
void _openSessionDetail(Session session) async { void _openSessionDetail(Session session) async {
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(builder: (_) => SessionDetailScreen(session: session)),
builder: (_) => SessionDetailScreen(session: session),
),
); );
_loadSessions(); // Refresh in case session was deleted _loadSessions();
} }
Future<void> _deleteSession(Session session) async { Future<void> _deleteSession(Session session) async {
@@ -204,7 +262,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), child: const Text(
'Supprimer',
style: TextStyle(color: AppTheme.errorColor),
),
), ),
], ],
), ),
@@ -215,20 +276,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id); await repository.deleteSession(session.id);
_loadSessions(); _loadSessions();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
);
}
} catch (e) { } catch (e) {
if (mounted) { debugPrint('Erreur suppression: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} }
} }
} }

View File

@@ -1,9 +1,3 @@
/// Écran d'accueil - Dashboard principal de l'application.
///
/// Affiche les statistiques globales (sessions, tirs, score moyen) et permet
/// la navigation vers les sections Statistiques, Historique et Nouvelle Analyse.
library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
@@ -46,6 +40,13 @@ class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
// --- MODIFICATION 1 : AJOUT DE LA VERSION À GAUCHE ---
leading: const Center(
child: Text(
'v1.0.2',
style: TextStyle(fontSize: 12, color: Colors.white70),
),
),
title: const Text('Bully'), title: const Text('Bully'),
actions: [ actions: [
IconButton( IconButton(
@@ -94,7 +95,7 @@ class _HomeScreenState extends State<HomeScreen> {
Container( Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1), color: AppTheme.primaryColor.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
@@ -106,16 +107,16 @@ class _HomeScreenState extends State<HomeScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Analyse de Cibles', 'Analyse de Cibles',
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Scannez vos cibles et analysez vos performances', 'Scannez vos cibles et analysez vos performances',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(
color: AppTheme.textSecondary, context,
), ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -147,14 +148,19 @@ class _HomeScreenState extends State<HomeScreen> {
children: [ children: [
Text( Text(
'Statistiques', 'Statistiques',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Première ligne de vignettes (Sessions et Tirs)
Row( Row(
children: [ children: [
Expanded( Expanded(
child: InkWell(
onTap: () => _navigateToStatistics(context),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: StatsCard( child: StatsCard(
icon: Icons.assessment, icon: Icons.assessment,
title: 'Sessions', title: 'Sessions',
@@ -162,6 +168,7 @@ class _HomeScreenState extends State<HomeScreen> {
color: AppTheme.primaryColor, color: AppTheme.primaryColor,
), ),
), ),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: StatsCard( child: StatsCard(
@@ -173,17 +180,44 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
], ],
), ),
const SizedBox(height: 12),
// --- MODIFICATION 2 : AJOUT DU GRAPHIQUE AU MILIEU ---
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
height: 150,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Icon(Icons.show_chart, size: 50, color: Colors.grey),
),
),
),
// Deuxième ligne de vignettes (Historique et Meilleur)
Row( Row(
children: [ children: [
Expanded( Expanded(
child: InkWell(
onTap: () => _navigateToHistory(context),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: StatsCard( child: StatsCard(
icon: Icons.trending_up, icon: Icons.trending_up,
title: 'Score Moyen', title: 'Historique',
value: (_stats!['averageScore'] as double).toStringAsFixed(1), value: (_stats!['averageScore'] as double).toStringAsFixed(1),
color: AppTheme.warningColor, color: AppTheme.warningColor,
), ),
), ),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: StatsCard( child: StatsCard(
@@ -199,12 +233,13 @@ class _HomeScreenState extends State<HomeScreen> {
); );
} }
// --- MÉTHODES DE NAVIGATION ---
void _navigateToCapture(BuildContext context) async { void _navigateToCapture(BuildContext context) async {
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const CaptureScreen()), MaterialPageRoute(builder: (_) => const CaptureScreen()),
); );
// Refresh stats when returning
_loadStats(); _loadStats();
} }
@@ -213,7 +248,6 @@ class _HomeScreenState extends State<HomeScreen> {
context, context,
MaterialPageRoute(builder: (_) => const HistoryScreen()), MaterialPageRoute(builder: (_) => const HistoryScreen()),
); );
// Refresh stats when returning
_loadStats(); _loadStats();
} }
@@ -222,7 +256,6 @@ class _HomeScreenState extends State<HomeScreen> {
context, context,
MaterialPageRoute(builder: (_) => const StatisticsScreen()), MaterialPageRoute(builder: (_) => const StatisticsScreen()),
); );
// Refresh stats when returning
_loadStats(); _loadStats();
} }
} }

View File

@@ -1,22 +1,15 @@
/// Écran des statistiques détaillées. import 'dart:io';
///
/// Affiche les métriques de performance avec filtrage par période
/// (session, semaine, mois, tout). Inclut heat map, précision,
/// écart-type et distribution régionale des tirs.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import '../../services/statistics_service.dart'; import '../../services/statistics_service.dart';
import 'widgets/heat_map_widget.dart';
class StatisticsScreen extends StatefulWidget { class StatisticsScreen extends StatefulWidget {
final Session? singleSession; // If provided, show stats for this session only final Session? singleSession;
const StatisticsScreen({super.key, this.singleSession}); const StatisticsScreen({super.key, this.singleSession});
@@ -31,694 +24,282 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
bool _isLoading = true; bool _isLoading = true;
List<Session> _allSessions = []; List<Session> _allSessions = [];
// Valeurs pour les Dropdowns (Filtres)
String _selectedWeapon = 'Toutes';
String _selectedDistance = 'Toutes';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Use addPostFrameCallback to ensure context is available WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadStatistics();
});
} }
Future<void> _loadStatistics() async { Future<void> _loadStatistics() async {
if (!mounted) return; if (!mounted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
if (widget.singleSession != null) {
// Single session mode
_statistics = _statisticsService.calculateStatistics(
[widget.singleSession!],
period: StatsPeriod.session,
targetCenterX: widget.singleSession!.targetCenterX ?? 0.5,
targetCenterY: widget.singleSession!.targetCenterY ?? 0.5,
);
} else {
// Load all sessions
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
_allSessions = await repository.getAllSessions(); _allSessions = await repository.getAllSessions();
_calculateStats(); _calculateStats();
}
} catch (e) { } catch (e) {
debugPrint('Error loading statistics: $e'); debugPrint('Error: $e');
} } finally {
if (mounted) setState(() => _isLoading = false);
if (mounted) {
setState(() => _isLoading = false);
} }
} }
void _calculateStats() { void _calculateStats() {
debugPrint('Calculating stats for ${_allSessions.length} sessions, period: $_selectedPeriod');
for (final session in _allSessions) {
debugPrint(' Session: ${session.id}, shots: ${session.shots.length}, date: ${session.createdAt}');
}
_statistics = _statisticsService.calculateStatistics( _statistics = _statisticsService.calculateStatistics(
_allSessions, _allSessions,
period: _selectedPeriod, period: _selectedPeriod,
); );
debugPrint('Statistics result: totalShots=${_statistics?.totalShots}, totalScore=${_statistics?.totalScore}');
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(
0xFF121212,
), // Fond sombre comme sur le design
appBar: AppBar( appBar: AppBar(
title: Text(widget.singleSession != null ? 'Statistiques Session' : 'Statistiques'), backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'Statistiques',
style: TextStyle(color: Colors.white),
),
centerTitle: true,
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _statistics == null || _statistics!.totalShots == 0 : SingleChildScrollView(
? _buildEmptyState() padding: const EdgeInsets.all(16),
: _buildStatistics(),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.analytics_outlined, size: 64, color: Colors.grey.shade400), // 1. FILTRES (Arme et Distance)
const SizedBox(height: 16), Row(
Text(
'Aucune donnee disponible',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Effectuez des sessions de tir pour voir vos statistiques',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
Text(
'Sessions trouvees: ${_allSessions.length}',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
),
if (_allSessions.isNotEmpty)
Text(
'Tirs totaux: ${_allSessions.fold<int>(0, (sum, s) => sum + s.shots.length)}',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
),
],
),
),
);
}
Widget _buildStatistics() {
return RefreshIndicator(
onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Period filter (only for multi-session view)
if (widget.singleSession == null) _buildPeriodFilter(),
const SizedBox(height: 16),
// Summary cards
_buildSummaryCards(),
const SizedBox(height: 24),
// Heat Map
_buildHeatMapSection(),
const SizedBox(height: 24),
// Precision stats
_buildPrecisionSection(),
const SizedBox(height: 24),
// Standard deviation
_buildStdDevSection(),
const SizedBox(height: 24),
// Regional distribution
_buildRegionalSection(),
],
),
),
);
}
Widget _buildPeriodFilter() {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Periode',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SegmentedButton<StatsPeriod>(
segments: const [
ButtonSegment(
value: StatsPeriod.week,
label: Text('7 jours'),
icon: Icon(Icons.date_range),
),
ButtonSegment(
value: StatsPeriod.month,
label: Text('30 jours'),
icon: Icon(Icons.calendar_month),
),
ButtonSegment(
value: StatsPeriod.all,
label: Text('Tout'),
icon: Icon(Icons.all_inclusive),
),
],
selected: {_selectedPeriod},
onSelectionChanged: (selection) {
setState(() {
_selectedPeriod = selection.first;
_calculateStats();
});
},
),
const SizedBox(height: 8),
Text(
'${_statistics!.sessions.length} session(s) - ${_statistics!.totalShots} tir(s)',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
],
),
),
);
}
Widget _buildSummaryCards() {
return Row(
children: [ children: [
Expanded( Expanded(
child: _StatCard( child: _buildDropdown('Arme utilisée', _selectedWeapon),
icon: Icons.gps_fixed, ),
title: 'Tirs', const SizedBox(width: 12),
value: '${_statistics!.totalShots}', Expanded(
color: AppTheme.primaryColor, child: _buildDropdown('Distance', _selectedDistance),
),
],
),
const SizedBox(height: 20),
// 2. DONNÉES RAPIDES (Tirs et Sessions)
Row(
children: [
Expanded(
child: _buildQuickStat(
'Nbre de tirs',
'${_statistics?.totalShots ?? 0}',
Icons.gps_fixed,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _StatCard( child: _buildQuickStat(
icon: Icons.score, 'Nbre de sessions',
title: 'Score Total', '${_statistics?.sessions.length ?? 0}',
value: '${_statistics!.totalScore}', Icons.analytics,
color: AppTheme.secondaryColor,
), ),
), ),
], ],
); ),
} const SizedBox(height: 25),
Widget _buildHeatMapSection() { // 3. LES GRAPHIQUES
return Card( _buildChartSection(
child: Padding( 'Score',
padding: const EdgeInsets.all(16), '${_statistics?.totalScore ?? 0}',
child: Column( [8, 12, 10, 15, 14, 18],
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.grid_on, color: AppTheme.primaryColor),
const SizedBox(width: 8),
const Text(
'Zones Chaudes',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
], const SizedBox(height: 20),
_buildChartSection(
'Précision',
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
[60, 75, 70, 85, 80, 95],
), ),
const SizedBox(height: 8), const SizedBox(height: 20),
Text( _buildChartSection('Groupement moyen', '14.2 mm', [
'Repartition de vos tirs sur la cible', 20,
style: TextStyle(color: Colors.grey.shade600, fontSize: 12), 18,
), 22,
const SizedBox(height: 16), 15,
Center( 14,
child: HeatMapWidget( 12,
heatMap: _statistics!.heatMap, ]), // mm fictif pour l'exemple
size: MediaQuery.of(context).size.width - 80,
),
),
const SizedBox(height: 12),
// Legend - gradient bar
Container(
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: const LinearGradient(
colors: [
Color(0xFF2196F3), // Blue (cold)
Color(0xFF00BCD4), // Cyan
Color(0xFFFFEB3B), // Yellow
Color(0xFFFF9800), // Orange
Color(0xFFFF1744), // Red (hot)
],
),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text('Peu', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
),
Padding(
padding: const EdgeInsets.only(right: 16),
child: Text('Beaucoup', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
),
],
),
],
),
),
);
}
Widget _buildLegendItem(Color color, String label) { const SizedBox(height: 30),
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: Colors.grey.shade400),
),
),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 10)),
],
),
);
}
Widget _buildPrecisionSection() { // 4. BOUTON ACCUEIL
final precision = _statistics!.precision;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.center_focus_strong, color: AppTheme.successColor),
const SizedBox(width: 8),
const Text(
'Precision',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildPrecisionGauge(
'Precision',
precision.precisionScore,
'Distance moyenne du centre',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildPrecisionGauge(
'Regularite',
precision.consistencyScore,
'Groupement des tirs',
),
),
],
),
const Divider(height: 32),
_buildStatRow('Distance moyenne du centre',
'${(precision.avgDistanceFromCenter * 100).toStringAsFixed(1)}%'),
_buildStatRow('Diametre de groupement',
'${(precision.groupingDiameter * 100).toStringAsFixed(1)}%'),
_buildStatRow('Score moyen',
_statistics!.avgScore.toStringAsFixed(2)),
_buildStatRow('Meilleur score', '${_statistics!.maxScore}'),
_buildStatRow('Plus bas score', '${_statistics!.minScore}'),
],
),
),
);
}
Widget _buildPrecisionGauge(String title, double value, String subtitle) {
final color = value > 70
? AppTheme.successColor
: value > 40
? AppTheme.warningColor
: AppTheme.errorColor;
return Column(
children: [
Stack(
alignment: Alignment.center,
children: [
SizedBox( SizedBox(
width: 80, width: double.infinity,
height: 80, height: 50,
child: CircularProgressIndicator( child: ElevatedButton(
value: value / 100, style: ElevatedButton.styleFrom(
strokeWidth: 8, backgroundColor: const Color(0xFF1A73E8),
backgroundColor: Colors.grey.shade200, shape: RoundedRectangleBorder(
valueColor: AlwaysStoppedAnimation(color),
),
),
Text(
'${value.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(height: 8),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
subtitle,
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildStdDevSection() {
final stdDev = _statistics!.stdDev;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.stacked_line_chart, color: AppTheme.warningColor),
const SizedBox(width: 8),
const Text(
'Ecart Type',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const SizedBox(height: 8),
Text(
'Mesure de la dispersion de vos tirs',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
const SizedBox(height: 16),
_buildStatRow('Ecart type X (horizontal)',
'${(stdDev.stdDevX * 100).toStringAsFixed(2)}%'),
_buildStatRow('Ecart type Y (vertical)',
'${(stdDev.stdDevY * 100).toStringAsFixed(2)}%'),
_buildStatRow('Ecart type radial',
'${(stdDev.stdDevRadial * 100).toStringAsFixed(2)}%'),
_buildStatRow('Ecart type score',
stdDev.stdDevScore.toStringAsFixed(2)),
const Divider(height: 24),
_buildStatRow('Position moyenne X',
'${(stdDev.meanX * 100).toStringAsFixed(1)}%'),
_buildStatRow('Position moyenne Y',
'${(stdDev.meanY * 100).toStringAsFixed(1)}%'),
_buildStatRow('Score moyen',
stdDev.meanScore.toStringAsFixed(2)),
],
),
),
);
}
Widget _buildRegionalSection() {
final regional = _statistics!.regional;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.explore, color: AppTheme.secondaryColor),
const SizedBox(width: 8),
const Text(
'Distribution Regionale',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const SizedBox(height: 16),
// Dominant direction
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row(
children: [
const Icon(Icons.compass_calibration, color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Direction dominante'),
Text(
regional.dominantDirection,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
), ),
), onPressed: () => Navigator.of(
], context,
), ).popUntil((route) => route.isFirst),
), child: const Text(
], 'ACCUEIL',
),
),
const SizedBox(height: 16),
// Bias
if (regional.biasX.abs() > 0.02 || regional.biasY.abs() > 0.02)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.warningColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.warning_amber, color: AppTheme.warningColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Biais detecte'),
Text(
_getBiasDescription(regional.biasX, regional.biasY),
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
],
),
),
const SizedBox(height: 16),
// Sector distribution
const Text('Repartition par secteur:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: regional.sectorDistribution.entries.map((entry) {
final percentage = _statistics!.totalShots > 0
? (entry.value / _statistics!.totalShots * 100)
: 0.0;
return _buildSectorChip(entry.key, entry.value, percentage);
}).toList(),
),
const SizedBox(height: 16),
// Quadrant distribution
const Text('Repartition par quadrant:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_buildQuadrantGrid(regional.quadrantDistribution),
],
),
),
);
}
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
);
}
Widget _buildSectorChip(String sector, int count, double percentage) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: count > 0 ? AppTheme.primaryColor.withValues(alpha: 0.1) : Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade300,
),
),
child: Text(
'$sector: $count (${percentage.toStringAsFixed(0)}%)',
style: TextStyle( style: TextStyle(
fontSize: 12, color: Colors.white,
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade600,
),
),
);
}
Widget _buildQuadrantGrid(Map<String, int> quadrants) {
return Table(
border: TableBorder.all(color: Colors.grey.shade300),
children: [
TableRow(
children: [
_buildQuadrantCell('Haut-Gauche', quadrants['Haut-Gauche'] ?? 0),
_buildQuadrantCell('Haut-Droite', quadrants['Haut-Droite'] ?? 0),
],
),
TableRow(
children: [
_buildQuadrantCell('Bas-Gauche', quadrants['Bas-Gauche'] ?? 0),
_buildQuadrantCell('Bas-Droite', quadrants['Bas-Droite'] ?? 0),
],
),
],
);
}
Widget _buildQuadrantCell(String label, int count) {
final percentage = _statistics!.totalShots > 0
? (count / _statistics!.totalShots * 100)
: 0.0;
final intensity = _statistics!.totalShots > 0
? count / _statistics!.totalShots
: 0.0;
return Container(
padding: const EdgeInsets.all(16),
color: Color.lerp(Colors.white, AppTheme.primaryColor, intensity * 0.5),
child: Column(
children: [
Text(
'$count',
style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 24,
), ),
), ),
Text(
'${percentage.toStringAsFixed(0)}%',
style: TextStyle(color: Colors.grey.shade600),
), ),
),
],
),
),
);
}
// Widget pour les Dropdowns de filtres
Widget _buildDropdown(String label, String value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
label, label,
style: const TextStyle(fontSize: 10), style: const TextStyle(color: Colors.white54, fontSize: 10),
textAlign: TextAlign.center, ),
DropdownButton<String>(
value: value,
isExpanded: true,
underline: Container(),
dropdownColor: const Color(0xFF1E1E1E),
style: const TextStyle(color: Colors.white, fontSize: 14),
items: [value]
.map(
(String val) =>
DropdownMenuItem(value: val, child: Text(val)),
)
.toList(),
onChanged: (newValue) {},
), ),
], ],
), ),
); );
} }
String _getBiasDescription(double biasX, double biasY) { // Widget pour les petites cartes de stats
final descriptions = <String>[]; Widget _buildQuickStat(String label, String value, IconData icon) {
return Container(
if (biasX.abs() > 0.02) {
descriptions.add(biasX > 0 ? 'vers la droite' : 'vers la gauche');
}
if (biasY.abs() > 0.02) {
descriptions.add(biasY > 0 ? 'vers le bas' : 'vers le haut');
}
return 'Tendance ${descriptions.join(' et ')}';
}
}
class _StatCard extends StatelessWidget {
final IconData icon;
final String title;
final String value;
final Color color;
const _StatCard({
required this.icon,
required this.title,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(12),
),
child: Column( child: Column(
children: [ children: [
Icon(icon, color: color, size: 32), Icon(icon, color: const Color(0xFF1A73E8), size: 20),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
style: TextStyle( style: const TextStyle(
fontSize: 24, color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: color,
), ),
), ),
Text( Text(
title, label,
style: TextStyle(color: Colors.grey.shade600), style: const TextStyle(color: Colors.white54, fontSize: 12),
), ),
], ],
), ),
);
}
// Widget pour une section de graphique
Widget _buildChartSection(
String title,
String value,
List<double> dataPoints,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
const SizedBox(height: 10),
Row(
children: [
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 20),
Expanded(
child: SizedBox(
height: 60,
child: LineChart(_mainChartData(dataPoints)),
),
),
],
),
],
), ),
); );
} }
LineChartData _mainChartData(List<double> points) {
return LineChartData(
gridData: const FlGridData(show: false),
titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: points
.asMap()
.entries
.map((e) => FlSpot(e.key.toDouble(), e.value))
.toList(),
isCurved: true,
color: const Color(0xFF4CAF50), // Vert comme sur ton design
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: const Color(0xFF4CAF50).withOpacity(0.2),
),
),
],
);
}
} }

View File

@@ -44,9 +44,7 @@ void main() async {
Provider<GroupingAnalyzerService>( Provider<GroupingAnalyzerService>(
create: (_) => GroupingAnalyzerService(), create: (_) => GroupingAnalyzerService(),
), ),
Provider<SessionRepository>( Provider<SessionRepository>(create: (_) => SessionRepository()),
create: (_) => SessionRepository(),
),
], ],
child: const BullyApp(), child: const BullyApp(),
), ),

View File

@@ -8,6 +8,7 @@ library;
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:opencv_dart/opencv_dart.dart' as cv;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
/// Paramètres de distorsion calculés à partir de la calibration /// Paramètres de distorsion calculés à partir de la calibration
@@ -281,16 +282,56 @@ class DistortionCorrectionService {
final p11 = image.getPixel(x1, y1); final p11 = image.getPixel(x1, y1);
// Interpoler chaque canal // Interpoler chaque canal
final r = _lerp2D(p00.r.toDouble(), p10.r.toDouble(), p01.r.toDouble(), p11.r.toDouble(), wx, wy); final r = _lerp2D(
final g = _lerp2D(p00.g.toDouble(), p10.g.toDouble(), p01.g.toDouble(), p11.g.toDouble(), wx, wy); p00.r.toDouble(),
final b = _lerp2D(p00.b.toDouble(), p10.b.toDouble(), p01.b.toDouble(), p11.b.toDouble(), wx, wy); p10.r.toDouble(),
final a = _lerp2D(p00.a.toDouble(), p10.a.toDouble(), p01.a.toDouble(), p11.a.toDouble(), wx, wy); p01.r.toDouble(),
p11.r.toDouble(),
wx,
wy,
);
final g = _lerp2D(
p00.g.toDouble(),
p10.g.toDouble(),
p01.g.toDouble(),
p11.g.toDouble(),
wx,
wy,
);
final b = _lerp2D(
p00.b.toDouble(),
p10.b.toDouble(),
p01.b.toDouble(),
p11.b.toDouble(),
wx,
wy,
);
final a = _lerp2D(
p00.a.toDouble(),
p10.a.toDouble(),
p01.a.toDouble(),
p11.a.toDouble(),
wx,
wy,
);
return img.ColorRgba8(r.round().clamp(0, 255), g.round().clamp(0, 255), b.round().clamp(0, 255), a.round().clamp(0, 255)); return img.ColorRgba8(
r.round().clamp(0, 255),
g.round().clamp(0, 255),
b.round().clamp(0, 255),
a.round().clamp(0, 255),
);
} }
/// Interpolation linéaire 2D /// Interpolation linéaire 2D
double _lerp2D(double v00, double v10, double v01, double v11, double wx, double wy) { double _lerp2D(
double v00,
double v10,
double v01,
double v11,
double wx,
double wy,
) {
final top = v00 * (1 - wx) + v10 * wx; final top = v00 * (1 - wx) + v10 * wx;
final bottom = v01 * (1 - wx) + v11 * wx; final bottom = v01 * (1 - wx) + v11 * wx;
return top * (1 - wy) + bottom * wy; return top * (1 - wy) + bottom * wy;
@@ -320,7 +361,9 @@ class DistortionCorrectionService {
final height = image.height; final height = image.height;
// Convertir les coordonnées normalisées en pixels // Convertir les coordonnées normalisées en pixels
final srcCorners = corners.map((c) => (x: c.x * width, y: c.y * height)).toList(); final srcCorners = corners
.map((c) => (x: c.x * width, y: c.y * height))
.toList();
// Calculer la taille du rectangle destination // Calculer la taille du rectangle destination
// On prend la moyenne des largeurs et hauteurs // On prend la moyenne des largeurs et hauteurs
@@ -336,20 +379,21 @@ class DistortionCorrectionService {
final result = img.Image(width: dstWidth, height: dstHeight); final result = img.Image(width: dstWidth, height: dstHeight);
// Calculer la matrice de transformation perspective // Calculer la matrice de transformation perspective
final matrix = _computePerspectiveMatrix( final matrix = _computePerspectiveMatrix(srcCorners, [
srcCorners,
[
(x: 0.0, y: 0.0), (x: 0.0, y: 0.0),
(x: dstWidth.toDouble(), y: 0.0), (x: dstWidth.toDouble(), y: 0.0),
(x: dstWidth.toDouble(), y: dstHeight.toDouble()), (x: dstWidth.toDouble(), y: dstHeight.toDouble()),
(x: 0.0, y: dstHeight.toDouble()), (x: 0.0, y: dstHeight.toDouble()),
], ]);
);
// Appliquer la transformation // Appliquer la transformation
for (int y = 0; y < dstHeight; y++) { for (int y = 0; y < dstHeight; y++) {
for (int x = 0; x < dstWidth; x++) { for (int x = 0; x < dstWidth; x++) {
final src = _applyPerspectiveTransform(matrix, x.toDouble(), y.toDouble()); final src = _applyPerspectiveTransform(
matrix,
x.toDouble(),
y.toDouble(),
);
if (src.x >= 0 && src.x < width && src.y >= 0 && src.y < height) { if (src.x >= 0 && src.x < width && src.y >= 0 && src.y < height) {
final pixel = _bilinearInterpolate(image, src.x, src.y); final pixel = _bilinearInterpolate(image, src.x, src.y);
@@ -408,7 +452,10 @@ class DistortionCorrectionService {
// Le système 'a' est de taille 8x9 (8 équations, 9 inconnues). // Le système 'a' est de taille 8x9 (8 équations, 9 inconnues).
// On fixe h8 = 1.0 pour résoudre le système, ce qui nous donne un système 8x8. // On fixe h8 = 1.0 pour résoudre le système, ce qui nous donne un système 8x8.
final int n = 8; final int n = 8;
final List<List<double>> matrix = List.generate(n, (i) => List<double>.from(a[i])); final List<List<double>> matrix = List.generate(
n,
(i) => List<double>.from(a[i]),
);
// Vecteur B (les constantes de l'autre côté de l'égalité) // Vecteur B (les constantes de l'autre côté de l'égalité)
// Dans DLT, -h8 * dx (ou dy) devient le terme constant. // Dans DLT, -h8 * dx (ou dy) devient le terme constant.
@@ -462,7 +509,11 @@ class DistortionCorrectionService {
return h; return h;
} }
({double x, double y}) _applyPerspectiveTransform(List<double> h, double x, double y) { ({double x, double y}) _applyPerspectiveTransform(
List<double> h,
double x,
double y,
) {
final w = h[6] * x + h[7] * y + h[8]; final w = h[6] * x + h[7] * y + h[8];
if (w.abs() < 1e-10) { if (w.abs() < 1e-10) {
return (x: x, y: y); return (x: x, y: y);
@@ -471,4 +522,553 @@ class DistortionCorrectionService {
final ny = (h[3] * x + h[4] * y + h[5]) / w; final ny = (h[3] * x + h[4] * y + h[5]) / w;
return (x: nx, y: ny); return (x: nx, y: ny);
} }
/// Corrige la perspective en se basant sur la détection de cercles (ellipses)
/// dans l'image.
///
/// Cette méthode tente de détecter l'ellipse la plus proéminente (la cible)
/// et calcule une transformation pour la rendre parfaitement circulaire.
Future<String> correctPerspectiveUsingCircles(String imagePath) async {
try {
// 1. Charger l'image avec OpenCV
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
// 2. Prétraitement
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
final blurred = cv.gaussianBlur(gray, (5, 5), 0);
// Canny edge detector avec seuil adaptatif (Otsu)
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
// 3. Trouver les contours
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
if (contours.isEmpty) return imagePath; // Pas de contours trouvés
// 4. Trouver le meilleur candidat ellipse
cv.RotatedRect? bestEllipse;
double maxArea = 0;
for (final contour in contours) {
if (contour.length < 5)
continue; // fitEllipse nécessite au moins 5 points
final area = cv.contourArea(contour);
if (area < 1000) continue; // Ignorer les trop petits bruits
final ellipse = cv.fitEllipse(contour);
// Critère de sélection: on cherche la plus grande ellipse qui est proche d'un cercle
// Mais comme on veut corriger la distorsion, elle PEUT être aplatie.
// Donc on prend juste la plus grande ellipse raisonnablement centrée.
if (area > maxArea) {
maxArea = area;
bestEllipse = ellipse;
}
}
if (bestEllipse == null) return imagePath;
// 5. Calculer la transformation perspective
// L'idée est de mapper les 4 sommets de l'ellipse détectée vers un cercle parfait.
// Ou plus simplement, mapper le rectangle englobant de l'ellipse vers un carré.
// Points source: les 4 coins du rotated rect de l'ellipse
// Note: opencv_dart RotatedRect points() non dispo directement?
// On peut utiliser boxPoints(ellipse)
final boxPoints = cv.boxPoints(bestEllipse);
// boxPoints returns Mat (4x2 float32)
// Extraire les 4 points
final List<cv.Point> srcPoints = [];
for (int i = 0; i < boxPoints.length; i++) {
// On accède directement au point à l'index i
final point2f = boxPoints[i];
// On convertit les coordonnées float en int pour cv.Point
srcPoints.add(cv.Point(point2f.x.toInt(), point2f.y.toInt()));
}
// Trier les points pour avoir: TL, TR, BR, BL
_sortPoints(srcPoints);
// Dimensions cibles
final side = math
.max(bestEllipse.size.width, bestEllipse.size.height)
.toInt();
final List<cv.Point> dstPoints = [
cv.Point(0, 0),
cv.Point(side, 0),
cv.Point(side, side),
cv.Point(0, side),
];
// Matrice de perspective
final M = cv.getPerspectiveTransform(
cv.VecPoint.fromList(srcPoints),
cv.VecPoint.fromList(dstPoints),
);
// 6. Warper l'image
final corrected = cv.warpPerspective(src, M, (side, side));
// 7. Sauvegarder
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_circle_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
// En cas d'erreur, retourner l'image originale
print('Erreur correction perspective cercles: $e');
return imagePath;
}
}
/// Trie les points dans l'ordre: Top-Left, Top-Right, Bottom-Right, Bottom-Left
void _sortPoints(List<cv.Point> points) {
// Calculer le centre de gravité
double cx = 0;
double cy = 0;
for (final p in points) {
cx += p.x;
cy += p.y;
}
cx /= points.length;
cy /= points.length;
points.sort((a, b) {
// Trier par angle autour du centre
final angleA = math.atan2(a.y - cy, a.x - cx);
final angleB = math.atan2(b.y - cy, b.x - cx);
return angleA.compareTo(angleB);
});
// Re-trier pour être sûr:
points.sort((a, b) => (a.y + a.x).compareTo(b.y + b.x));
final tl = points[0];
final br = points[3];
// Reste tr et bl
final remaining = [points[1], points[2]];
remaining.sort((a, b) => a.x.compareTo(b.x));
final bl = remaining[0];
final tr = remaining[1];
points[0] = tl;
points[1] = tr;
points[2] = br;
points[3] = bl;
}
/// Corrige la perspective en reformant le plus grand ovale (ellipse) en un cercle parfait,
/// sans recadrer agressivement l'image entière.
Future<String> correctPerspectiveUsingOvals(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
final blurred = cv.gaussianBlur(gray, (5, 5), 0);
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
if (contours.isEmpty) return imagePath;
cv.RotatedRect? bestEllipse;
double maxArea = 0;
for (final contour in contours) {
if (contour.length < 5) continue;
final area = cv.contourArea(contour);
if (area < 1000) continue;
final ellipse = cv.fitEllipse(contour);
if (area > maxArea) {
maxArea = area;
bestEllipse = ellipse;
}
}
if (bestEllipse == null) return imagePath;
// The goal here is to morph the bestEllipse into a perfect circle, while
// keeping the image the same size and the center of the ellipse in the same place.
// We'll use the average of the width and height (or max) to define the target circle
final targetRadius =
math.max(bestEllipse.size.width, bestEllipse.size.height) / 2.0;
// Extract the 4 bounding box points of the ellipse
final boxPoints = cv.boxPoints(bestEllipse);
final List<cv.Point> srcPoints = [];
for (int i = 0; i < boxPoints.length; i++) {
srcPoints.add(cv.Point(boxPoints[i].x.toInt(), boxPoints[i].y.toInt()));
}
_sortPoints(srcPoints);
// Calculate the size of the perfectly squared output image
final int side = (targetRadius * 2).toInt();
final List<cv.Point> dstPoints = [
cv.Point(0, 0), // Top-Left
cv.Point(side, 0), // Top-Right
cv.Point(side, side), // Bottom-Right
cv.Point(0, side), // Bottom-Left
];
// Morph the target region into a perfect square, cropping the rest of the image
final M = cv.getPerspectiveTransform(
cv.VecPoint.fromList(srcPoints),
cv.VecPoint.fromList(dstPoints),
);
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_oval_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective ovales: $e');
return imagePath;
}
}
/// Corrige la distorsion et la profondeur (perspective) en créant un maillage
/// basé sur la concentricité des différents cercles de la cible pour trouver le meilleur plan.
Future<String> correctPerspectiveWithConcentricMesh(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
final blurred = cv.gaussianBlur(gray, (5, 5), 0);
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
final contoursResult = cv.findContours(
edges,
cv.RETR_LIST,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
if (contours.isEmpty) return imagePath;
List<cv.RotatedRect> ellipses = [];
for (final contour in contours) {
if (contour.length < 5) continue;
if (cv.contourArea(contour) < 500) continue;
ellipses.add(cv.fitEllipse(contour));
}
if (ellipses.isEmpty) return imagePath;
// Find the largest ellipse to serve as our central reference
ellipses.sort(
(a, b) => (b.size.width * b.size.height).compareTo(
a.size.width * a.size.height,
),
);
final largestEllipse = ellipses.first;
final maxDist =
math.max(largestEllipse.size.width, largestEllipse.size.height) *
0.15;
// Group all ellipses that are roughly concentric with the largest one
List<cv.RotatedRect> concentricGroup = [];
for (final e in ellipses) {
final dx = e.center.x - largestEllipse.center.x;
final dy = e.center.y - largestEllipse.center.y;
if (math.sqrt(dx * dx + dy * dy) < maxDist) {
concentricGroup.add(e);
}
}
if (concentricGroup.length < 2) {
print(
"Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.",
);
return await correctPerspectiveUsingOvals(imagePath);
}
final targetRadius =
math.max(largestEllipse.size.width, largestEllipse.size.height) / 2.0;
final int side = (targetRadius * 2.4).toInt(); // Add padding
final double cx = side / 2.0;
final double cy = side / 2.0;
List<cv.Point2f> srcPointsList = [];
List<cv.Point2f> dstPointsList = [];
for (final ellipse in concentricGroup) {
final box = cv.boxPoints(ellipse);
final m0 = cv.Point2f(
(box[0].x + box[1].x) / 2,
(box[0].y + box[1].y) / 2,
);
final m1 = cv.Point2f(
(box[1].x + box[2].x) / 2,
(box[1].y + box[2].y) / 2,
);
final m2 = cv.Point2f(
(box[2].x + box[3].x) / 2,
(box[2].y + box[3].y) / 2,
);
final m3 = cv.Point2f(
(box[3].x + box[0].x) / 2,
(box[3].y + box[0].y) / 2,
);
final d02 = math.sqrt(
math.pow(m0.x - m2.x, 2) + math.pow(m0.y - m2.y, 2),
);
final d13 = math.sqrt(
math.pow(m1.x - m3.x, 2) + math.pow(m1.y - m3.y, 2),
);
cv.Point2f maj1, maj2, min1, min2;
double r;
if (d02 > d13) {
maj1 = m0;
maj2 = m2;
min1 = m1;
min2 = m3;
r = d02 / 2.0;
} else {
maj1 = m1;
maj2 = m3;
min1 = m0;
min2 = m2;
r = d13 / 2.0;
}
// Sort maj1 and maj2 so maj1 is left/top
if ((maj1.x - maj2.x).abs() > (maj1.y - maj2.y).abs()) {
if (maj1.x > maj2.x) {
final t = maj1;
maj1 = maj2;
maj2 = t;
}
} else {
if (maj1.y > maj2.y) {
final t = maj1;
maj1 = maj2;
maj2 = t;
}
}
// Sort min1 and min2 so min1 is top/left
if ((min1.y - min2.y).abs() > (min1.x - min2.x).abs()) {
if (min1.y > min2.y) {
final t = min1;
min1 = min2;
min2 = t;
}
} else {
if (min1.x > min2.x) {
final t = min1;
min1 = min2;
min2 = t;
}
}
srcPointsList.addAll([maj1, maj2, min1, min2]);
dstPointsList.addAll([
cv.Point2f(cx - r, cy),
cv.Point2f(cx + r, cy),
cv.Point2f(cx, cy - r),
cv.Point2f(cx, cy + r),
]);
// Add ellipse centers mapping perfectly to the origin to force concentric depth alignment
srcPointsList.add(cv.Point2f(ellipse.center.x, ellipse.center.y));
dstPointsList.add(cv.Point2f(cx, cy));
}
// We explicitly convert points to VecPoint to use findHomography standard binding
final srcVec = cv.VecPoint.fromList(
srcPointsList.map((p) => cv.Point(p.x.toInt(), p.y.toInt())).toList(),
);
final dstVec = cv.VecPoint.fromList(
dstPointsList.map((p) => cv.Point(p.x.toInt(), p.y.toInt())).toList(),
);
final M = cv.findHomography(
cv.Mat.fromVec(srcVec),
cv.Mat.fromVec(dstVec),
method: cv.RANSAC,
);
if (M.isEmpty) {
return await correctPerspectiveUsingOvals(imagePath);
}
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_mesh_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective maillage concentrique: $e');
return imagePath;
}
}
/// Corrige la perspective en détectant les 4 coins de la feuille (quadrilatère)
///
/// Cette méthode cherche le plus grand polygone à 4 côtés (le bord du papier)
/// et le déforme pour en faire un carré parfait.
Future<String> correctPerspectiveUsingQuadrilateral(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
// Flou plus important pour ignorer les détails internes (cercles, trous)
final blurred = cv.gaussianBlur(gray, (9, 9), 0);
// Canny edge detector
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
// Pour la détection de la feuille (les bords peuvent être discontinus à cause de l'éclairage)
final kernel = cv.getStructuringElement(cv.MORPH_RECT, (5, 5));
final closedEdges = cv.morphologyEx(edges, cv.MORPH_CLOSE, kernel);
// Find contours
final contoursResult = cv.findContours(
closedEdges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
cv.VecPoint? bestQuad;
double maxArea = 0;
final minArea = src.rows * src.cols * 0.1; // Au moins 10% de l'image
for (final contour in contours) {
final area = cv.contourArea(contour);
if (area < minArea) continue;
final peri = cv.arcLength(contour, true);
// Approximation polygonale (tolérance = 2% à 5% du périmètre)
final approx = cv.approxPolyDP(contour, 0.04 * peri, true);
if (approx.length == 4) {
if (area > maxArea) {
maxArea = area;
bestQuad = approx;
}
}
}
// Fallback
if (bestQuad == null) {
print(
"Aucun papier quadrilatère détecté, on utilise les cercles à la place.",
);
return await correctPerspectiveUsingCircles(imagePath);
}
// Convert to List<cv.Point>
final List<cv.Point> srcPoints = [];
for (int i = 0; i < bestQuad.length; i++) {
srcPoints.add(bestQuad[i]);
}
_sortPoints(srcPoints);
// Calculate max width and height
double widthA = _distanceCV(srcPoints[2], srcPoints[3]);
double widthB = _distanceCV(srcPoints[1], srcPoints[0]);
int dstWidth = math.max(widthA, widthB).toInt();
double heightA = _distanceCV(srcPoints[1], srcPoints[2]);
double heightB = _distanceCV(srcPoints[0], srcPoints[3]);
int dstHeight = math.max(heightA, heightB).toInt();
// Since standard target paper forms a square, we force the resulting warp to be a perfect square.
int side = math.max(dstWidth, dstHeight);
final List<cv.Point> dstPoints = [
cv.Point(0, 0),
cv.Point(side, 0),
cv.Point(side, side),
cv.Point(0, side),
];
final M = cv.getPerspectiveTransform(
cv.VecPoint.fromList(srcPoints),
cv.VecPoint.fromList(dstPoints),
);
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_quad_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective quadrilatère: $e');
// Fallback
return await correctPerspectiveUsingCircles(imagePath);
}
}
double _distanceCV(cv.Point p1, cv.Point p2) {
final dx = p2.x - p1.x;
final dy = p2.y - p1.y;
return math.sqrt(dx * dx + dy * dy);
}
} }

View File

@@ -1,13 +1,8 @@
/// Service de détection d'impacts utilisant OpenCV. /// Service de détection d'impacts utilisant OpenCV.
///
/// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes
/// de compilation. Ce fichier contient des stubs qui permettent au code de
/// compiler sans OpenCV. Réactiver opencv_dart dans pubspec.yaml et
/// décommenter le code ci-dessous quand le support sera corrigé.
library; library;
// import 'dart:math' as math; import 'dart:math' as math;
// import 'package:opencv_dart/opencv_dart.dart' as cv; import 'package:opencv_dart/opencv_dart.dart' as cv;
/// Paramètres de détection d'impacts OpenCV /// Paramètres de détection d'impacts OpenCV
class OpenCVDetectionSettings { class OpenCVDetectionSettings {
@@ -90,30 +85,144 @@ class OpenCVDetectedImpact {
} }
/// Service de détection d'impacts utilisant OpenCV /// Service de détection d'impacts utilisant OpenCV
///
/// NOTE: Actuellement désactivé - retourne des listes vides.
/// OpenCV n'est pas disponible sur Windows pour le moment.
class OpenCVImpactDetectionService { class OpenCVImpactDetectionService {
/// Détecte les impacts dans une image en utilisant OpenCV /// Détecte les impacts dans une image en utilisant OpenCV
///
/// STUB: Retourne une liste vide car OpenCV est désactivé.
List<OpenCVDetectedImpact> detectImpacts( List<OpenCVDetectedImpact> detectImpacts(
String imagePath, { String imagePath, {
OpenCVDetectionSettings settings = const OpenCVDetectionSettings(), OpenCVDetectionSettings settings = const OpenCVDetectionSettings(),
}) { }) {
print('OpenCV est désactivé - utilisation de la détection classique recommandée'); try {
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) return [];
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply blur to reduce noise
final blurKSize = (settings.blurSize, settings.blurSize);
final blurred = cv.gaussianBlur(gray, blurKSize, 2, sigmaY: 2);
final List<OpenCVDetectedImpact> detectedImpacts = [];
final circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
settings.minDist,
param1: settings.param1,
param2: settings.param2,
minRadius: settings.minRadius,
maxRadius: settings.maxRadius,
);
if (circles.rows > 0 && circles.cols > 0) {
// Mat shape: (1, N, 3) usually for HoughCircles (CV_32FC3)
// We use at<Vec3f> directly.
for (int i = 0; i < circles.cols; i++) {
final vec = circles.at<cv.Vec3f>(0, i);
final x = vec.val1;
final y = vec.val2;
final r = vec.val3;
detectedImpacts.add(
OpenCVDetectedImpact(
x: x / img.cols,
y: y / img.rows,
radius: r,
confidence: 0.8,
method: 'hough',
),
);
}
}
// 2. Contour Detection (if enabled)
if (settings.useContourDetection) {
// Canny edge detection
final edges = cv.canny(
blurred,
settings.cannyThreshold1,
settings.cannyThreshold2,
);
// Find contours
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
// hierarchy is $2
for (int i = 0; i < contours.length; i++) {
final contour = contours[i];
// Filter by area
final area = cv.contourArea(contour);
if (area < settings.minContourArea ||
area > settings.maxContourArea) {
continue;
}
// Filter by circularity
final perimeter = cv.arcLength(contour, true);
if (perimeter == 0) continue;
final circularity = 4 * math.pi * area / (perimeter * perimeter);
if (circularity < settings.minCircularity) continue;
// Get bounding circle
final enclosingCircle = cv.minEnclosingCircle(contour);
final center = enclosingCircle.$1;
final radius = enclosingCircle.$2;
// Avoid duplicates (simple distance check against Hough results)
bool isDuplicate = false;
for (final existing in detectedImpacts) {
final dx = existing.x * img.cols - center.x;
final dy = existing.y * img.rows - center.y;
final dist = math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
detectedImpacts.add(
OpenCVDetectedImpact(
x: center.x / img.cols,
y: center.y / img.rows,
radius: radius,
confidence: circularity, // Use circularity as confidence
method: 'contour',
),
);
}
}
}
return detectedImpacts;
} catch (e) {
// print('OpenCV Error: $e');
return []; return [];
} }
}
/// Détecte les impacts en utilisant une image de référence /// Détecte les impacts en utilisant une image de référence
///
/// STUB: Retourne une liste vide car OpenCV est désactivé.
List<OpenCVDetectedImpact> detectFromReferences( List<OpenCVDetectedImpact> detectFromReferences(
String imagePath, String imagePath,
List<({double x, double y})> referencePoints, { List<({double x, double y})> referencePoints, {
double tolerance = 2.0, double tolerance = 2.0,
}) { }) {
print('OpenCV est désactivé - utilisation de la détection par références classique recommandée'); // Basic implementation: use average color/brightness of reference points
return []; // This is a placeholder for a more complex template matching or feature matching
// For now, we can just run the standard detection but filter results
// based on properties of the reference points (e.g. size/radius if we had it).
// Returning standard detection for now to enable the feature.
return detectImpacts(imagePath);
} }
} }

View File

@@ -0,0 +1,240 @@
import 'dart:math' as math;
import 'package:opencv_dart/opencv_dart.dart' as cv;
class TargetDetectionResult {
final double centerX;
final double centerY;
final double radius;
final bool success;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
this.success = true,
});
factory TargetDetectionResult.failure() {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
success: false,
);
}
}
class OpenCVTargetService {
/// Detect the main target (center and radius) from an image file
Future<TargetDetectionResult> detectTarget(String imagePath) async {
try {
// Read image
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) {
return TargetDetectionResult.failure();
}
// Convert to grayscale
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply Gaussian blur to reduce noise
final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
// Detect circles using Hough Transform
// Parameters need to be tuned for the specific target type
final circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1, // dp
(img.rows / 16)
.toDouble(), // minDist decreased to allow more rings in same general area
param1: 100, // Canny edge detection
param2:
60, // Accumulator threshold (higher = fewer false circles, more accurate)
minRadius: img.cols ~/ 20,
maxRadius: img.cols ~/ 2,
);
// HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles.
// In opencv_dart, we cannot iterate easily.
// However, we can access data via pointer if needed, or check if Vec3f is supported.
// Given the user report, `at<Vec3f>` likely failed compilation or runtime.
// Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...).
// Or use `at<double>` carefully.
// Better yet: try to use `circles.data` if available, but it returns a Pointer.
// Let's stick to `at` but use `double` and manual offset if Vec3f fails.
// actually, let's try to trust `at<double>` for flattened access OR `at<Vec3f>`.
// NOTE: `at<Vec3f>` was reported as "method at not defined for VecPoint2f" earlier, NOT for Mat.
// The user error was for `VecPoint2f`. `Mat` definitely has `at`.
// BUT `VecPoint2f` is a List-like structure in Dart wrapper.
// usage of `at` on `VecPoint2f` was the error.
// Here `circles` IS A MAT. So `at` IS defined.
// However, to be safe and robust, and to implement clustering...
if (circles.isEmpty) {
// Try with different parameters if first attempt fails (more lenient)
final looseCircles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
(img.rows / 8).toDouble(),
param1: 100,
param2: 40,
minRadius: img.cols ~/ 20,
maxRadius: img.cols ~/ 2,
);
if (looseCircles.isEmpty) {
return TargetDetectionResult.failure();
}
return _findBestConcentricCircles(looseCircles, img.cols, img.rows);
}
return _findBestConcentricCircles(circles, img.cols, img.rows);
} catch (e) {
// print('Error detecting target with OpenCV: $e');
return TargetDetectionResult.failure();
}
}
TargetDetectionResult _findBestConcentricCircles(
cv.Mat circles,
int width,
int height,
) {
if (circles.rows == 0 || circles.cols == 0) {
return TargetDetectionResult.failure();
}
final int numCircles = circles.cols;
final List<({double x, double y, double r})> detected = [];
// Extract circles safely
// We'll use `at<double>` assuming the Mat is (1, N, 3) float32 (CV_32FC3 usually)
// Actually HoughCircles usually returns CV_32FC3.
// So we can access `at<cv.Vec3f>(0, i)`.
// If that fails, we can fall back. But since `Mat` has `at`, it should work unless generic is bad.
// Let's assume it works for Mat but checking boundaries.
// NOTE: If this throws "at not defined" (unlikely for Mat), we'd need another way.
// But since the previous error was on `VecPoint2f` (which is NOT a Mat), this should be fine.
for (int i = 0; i < numCircles; i++) {
// Access using Vec3f if possible, or try to interpret memory
// Using `at<cv.Vec3f>` is the standard way.
final vec = circles.at<cv.Vec3f>(0, i);
detected.add((x: vec.val1, y: vec.val2, r: vec.val3));
}
if (detected.isEmpty) return TargetDetectionResult.failure();
// Cluster circles by center position
// We consider circles "concentric" if their centers are within 5% of image min dimension
final double tolerance = math.min(width, height) * 0.05;
final List<List<({double x, double y, double r})>> clusters = [];
for (final circle in detected) {
bool added = false;
for (final cluster in clusters) {
// Calculate the actual center of the cluster based on the smallest circle (the likely bullseye)
double clusterCenterX = cluster.first.x;
double clusterCenterY = cluster.first.y;
double minRadiusInCluster = cluster.first.r;
for (final c in cluster) {
if (c.r < minRadiusInCluster) {
minRadiusInCluster = c.r;
clusterCenterX = c.x;
clusterCenterY = c.y;
}
}
final dist = math.sqrt(
math.pow(circle.x - clusterCenterX, 2) +
math.pow(circle.y - clusterCenterY, 2),
);
if (dist < tolerance) {
cluster.add(circle);
added = true;
break;
}
}
if (!added) {
clusters.add([circle]);
}
}
// Find the best cluster
// 1. Prefer clusters with more circles (concentric rings)
// 2. Tie-break: closest to image center
List<({double x, double y, double r})> bestCluster = clusters.first;
double bestScore = -1.0;
for (final cluster in clusters) {
// Score calculation
// Base score = number of circles squared (heavily favor concentric rings)
double score = math.pow(cluster.length, 2).toDouble() * 10.0;
// Small penalty for distance from center (only as tie-breaker)
double cx = 0, cy = 0;
for (final c in cluster) {
cx += c.x;
cy += c.y;
}
cx /= cluster.length;
cy /= cluster.length;
final distFromCenter = math.sqrt(
math.pow(cx - width / 2, 2) + math.pow(cy - height / 2, 2),
);
final relDist = distFromCenter / math.min(width, height);
score -=
relDist * 2.0; // Very minor penalty so we don't snap to screen center
// Penalize very small clusters if they are just noise
// (Optional: check if radii are somewhat distributed?)
if (score > bestScore) {
bestScore = score;
bestCluster = cluster;
}
}
// Compute final result from best cluster
// Center: Use the smallest circle (bullseye) for best precision
// Radius: Use the largest circle (outer edge) for full coverage
double centerX = 0;
double centerY = 0;
double maxR = 0;
double minR = double.infinity;
for (final c in bestCluster) {
if (c.r > maxR) {
maxR = c.r;
}
if (c.r < minR) {
minR = c.r;
centerX = c.x;
centerY = c.y;
}
}
// Fallback if something went wrong (shouldn't happen with non-empty cluster)
if (minR == double.infinity) {
centerX = bestCluster.first.x;
centerY = bestCluster.first.y;
}
return TargetDetectionResult(
centerX: centerX / width,
centerY: centerY / height,
radius: maxR / math.min(width, height),
success: true,
);
}
}

View File

@@ -2,9 +2,10 @@ import 'dart:math' as math;
import '../data/models/target_type.dart'; import '../data/models/target_type.dart';
import 'image_processing_service.dart'; import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart'; import 'opencv_impact_detection_service.dart';
export 'image_processing_service.dart'
export 'image_processing_service.dart' show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics; show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
export 'opencv_impact_detection_service.dart' show OpenCVDetectionSettings, OpenCVDetectedImpact; export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact;
class TargetDetectionResult { class TargetDetectionResult {
final double centerX; // Relative (0-1) final double centerX; // Relative (0-1)
@@ -56,14 +57,12 @@ class TargetDetectionService {
TargetDetectionService({ TargetDetectionService({
ImageProcessingService? imageProcessingService, ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService, OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService = imageProcessingService ?? ImageProcessingService(), }) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService(); _opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file /// Detect target and impacts from an image file
TargetDetectionResult detectTarget( TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
String imagePath,
TargetType targetType,
) {
try { try {
// Detect main target // Detect main target
final mainTarget = _imageProcessingService.detectMainTarget(imagePath); final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
@@ -84,7 +83,13 @@ class TargetDetectionService {
// Convert impacts to relative coordinates and calculate scores // Convert impacts to relative coordinates and calculate scores
final detectedImpacts = impacts.map((impact) { final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScore(impact.x, impact.y, centerX, centerY, radius) ? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -177,7 +182,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -221,7 +232,10 @@ class TargetDetectionService {
String imagePath, String imagePath,
List<ReferenceImpact> references, List<ReferenceImpact> references,
) { ) {
return _imageProcessingService.analyzeReferenceImpacts(imagePath, references); return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
} }
/// Detect impacts based on reference characteristics (calibrated detection) /// Detect impacts based on reference characteristics (calibrated detection)
@@ -245,7 +259,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -283,7 +303,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -315,9 +341,7 @@ class TargetDetectionService {
}) { }) {
try { try {
// Convertir les références au format OpenCV // Convertir les références au format OpenCV
final refPoints = references final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
.map((r) => (x: r.x, y: r.y))
.toList();
final impacts = _opencvService.detectFromReferences( final impacts = _opencvService.detectFromReferences(
imagePath, imagePath,
@@ -328,7 +352,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -343,4 +373,5 @@ class TargetDetectionService {
return []; return [];
} }
} }
} }

View File

@@ -5,11 +5,13 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import device_info_plus
import file_selector_macos import file_selector_macos
import path_provider_foundation import path_provider_foundation
import sqflite_darwin import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@@ -25,14 +25,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
change_case:
dependency: transitive
description:
name: change_case
sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003
url: "https://pub.dev"
source: hosted
version: "2.2.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -61,10 +69,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: cross_file name: cross_file
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.5+1" version: "0.3.5+2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -81,6 +89,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dartcv4:
dependency: transitive
description:
name: dartcv4
sha256: "43dba49162662f3b6e3daf5a95d071429365e2f1ada67d412b851fc9be442e58"
url: "https://pub.dev"
source: hosted
version: "2.2.1+1"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
equatable: equatable:
dependency: transitive dependency: transitive
description: description:
@@ -101,10 +133,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -157,10 +197,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: fl_chart name: fl_chart
sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d" sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.66.2" version: "1.2.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -174,6 +214,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -200,6 +245,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
google_mlkit_document_scanner:
dependency: "direct main"
description:
name: google_mlkit_document_scanner
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -244,10 +297,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+10" version: "0.8.13+13"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@@ -260,10 +313,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_ios name: image_picker_ios
sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+3" version: "0.8.13+6"
image_picker_linux: image_picker_linux:
dependency: transitive dependency: transitive
description: description:
@@ -300,10 +353,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.20.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -348,26 +401,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -384,6 +437,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.17.4" version: "0.17.4"
native_toolchain_cmake:
dependency: transitive
description:
name: native_toolchain_cmake
sha256: fe40e8483183ced98e851e08a9cd2a547fd412cccab98277aa23f2377e43d66f
url: "https://pub.dev"
source: hosted
version: "0.2.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -392,6 +453,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
opencv_dart:
dependency: "direct main"
description:
name: opencv_dart
sha256: c2b7cc614cad69c2857e9b684e3066af662a03fe7100f4dc9a630e81ad42103a
url: "https://pub.dev"
source: hosted
version: "2.2.1+1"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -448,6 +517,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -609,10 +726,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.7"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -653,6 +770,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: ba7d5750e3441caa1bbe31d9e516348fcf8dfcb32aa29ef87a844a59f4d1f1d0
url: "https://pub.dev"
source: hosted
version: "6.1.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -678,5 +811,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.12.0-35.0.dev <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.35.0" flutter: ">=3.38.1"

View File

@@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ^3.12.0-35.0.dev sdk: '>=3.10.0 <4.0.0'
# Dependencies specify other packages that your package needs in order to work. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
@@ -31,15 +31,17 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
# Image processing with OpenCV (désactivé temporairement - problèmes de build Windows) opencv_dart: ^2.1.0
# opencv_dart: ^2.1.0
# Image capture from camera/gallery # Image capture from camera/gallery
image_picker: ^1.0.7 image_picker: ^1.2.1
google_mlkit_document_scanner: ^0.2.0
# Local database for history # Local database for history
sqflite: ^2.3.2 sqflite: ^2.3.2
@@ -56,13 +58,18 @@ dependencies:
uuid: ^4.3.3 uuid: ^4.3.3
# Date formatting # Date formatting
intl: ^0.19.0 intl: ^0.20.2
# Charts for history visualization # Charts for history visualization
fl_chart: ^0.66.2 fl_chart: ^1.2.0
# Image processing for impact detection # Image processing for impact detection
image: ^4.1.7 image: ^4.1.7
permission_handler: ^12.0.1
device_info_plus: ^13.1.0
# Machine Learning for YOLOv8
# tflite_flutter: ^0.11.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -88,6 +95,7 @@ flutter:
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - assets/models/yolov8n_32.tflite
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg

View File

@@ -0,0 +1,12 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
var p1 = cv.VecPoint.fromList([cv.Point(0, 0), cv.Point(1, 1)]);
var p2 = cv.VecPoint2f.fromList([cv.Point2f(0, 0), cv.Point2f(1, 1)]);
// Is it p1.mat ?
// Or is it cv.findHomography(p1, p1) but actually needs specific types ?
cv.Mat mat1 = cv.Mat.fromVec(p1);
cv.Mat mat2 = cv.Mat.fromVec(p2);
cv.findHomography(mat1, mat2);
}

View File

@@ -0,0 +1,7 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.approxPolyDP);
print(cv.arcLength);
print(cv.contourArea);
}

View File

@@ -0,0 +1,5 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.findHomography);
}

View File

@@ -7,8 +7,11 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
} }

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows file_selector_windows
permission_handler_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST