Compare commits
33 Commits
dev
...
sans-mlkit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b904205a0 | ||
|
|
cec9838e9b | ||
|
|
8d5641c0a3 | ||
|
|
a3167fc2ba | ||
|
|
972bfbe0e9 | ||
|
|
f65f65112c | ||
|
|
beb20074f2 | ||
|
|
a102bfd5ef | ||
|
|
89b4f433b6 | ||
|
|
9fa3a6f46d | ||
|
|
8165d3bab3 | ||
|
|
ba9975f047 | ||
|
|
f7fecf0ef2 | ||
|
|
0564bdbb48 | ||
|
|
05356aaaea | ||
|
|
c4880ccb68 | ||
|
|
00ae0117c5 | ||
|
|
1d8124c8d8 | ||
|
|
600a6bef30 | ||
|
|
5d2e612b9c | ||
|
|
4e26431be5 | ||
|
|
559369c84d | ||
|
|
653aa5d5b0 | ||
|
|
fec33a327a | ||
|
|
8e64839fc6 | ||
|
|
331fda0ffb | ||
|
|
064f5c1fe3 | ||
|
|
b4226216eb | ||
|
|
2107f187b6 | ||
|
|
0c509bfdc3 | ||
|
|
71ad670ad8 | ||
|
|
625810234e | ||
|
|
9afe3c8508 |
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(flutter analyze:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(flutter clean:*)",
|
|
||||||
"Bash(flutter pub get:*)",
|
|
||||||
"Bash(flutter run:*)",
|
|
||||||
"Bash(cmake:*)",
|
|
||||||
"Bash(where:*)",
|
|
||||||
"Bash(winget search:*)",
|
|
||||||
"Bash(winget install:*)",
|
|
||||||
"Bash(\"/c/Program Files \\(x86\\)/Microsoft Visual Studio/Installer/vs_installer.exe\" modify --installPath \"C:\\\\Program Files \\(x86\\)\\\\Microsoft Visual Studio\\\\2022\\\\BuildTools\" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait)",
|
|
||||||
"Bash(cmd //c \"\"\"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\18\\\\Community\\\\Common7\\\\Tools\\\\VsDevCmd.bat\"\" && flutter run -d windows\")",
|
|
||||||
"Bash(flutter doctor:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
.gitignore
vendored
@@ -11,6 +11,7 @@
|
|||||||
.svn/
|
.svn/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
19
CLAUDE.md
@@ -41,10 +41,12 @@ flutter test --coverage
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Analyse de cibles de tir
|
### Capture de cibles de tir
|
||||||
- Support de cibles concentriques (anneaux) et silhouettes
|
- Support de cibles concentriques (anneaux) et silhouettes
|
||||||
- Chargement d'images de cibles depuis la galerie ou la caméra
|
- Chargement d'images de cibles depuis la galerie ou la caméra
|
||||||
- Détection automatique du centre et du rayon de la cible
|
- Aperçu caméra avec aide au cadrage : détection OpenCV de la cible (mire)
|
||||||
|
et indicateur de parallélisme par accéléromètre (pitch/roll)
|
||||||
|
- Écran de centrage/recadrage : rotation fine, déplacement pixel par pixel
|
||||||
|
|
||||||
### Calibration des cibles
|
### Calibration des cibles
|
||||||
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
|
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
|
||||||
@@ -52,14 +54,11 @@ flutter test --coverage
|
|||||||
- Slider global pour redimensionner tous les anneaux proportionnellement
|
- Slider global pour redimensionner tous les anneaux proportionnellement
|
||||||
- Visualisation en temps réel des zones de score
|
- Visualisation en temps réel des zones de score
|
||||||
|
|
||||||
### Détection d'impacts
|
### Placement des impacts
|
||||||
- **Ajout manuel** : cliquer sur l'image pour placer un impact
|
- **Éditeur d'impacts plein écran** : tap pour ajouter, tap sur un impact pour
|
||||||
- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables
|
l'éditer (score/suppression), appui long pour déplacer, pincer pour zoomer
|
||||||
- Seuil de luminosité
|
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de plotting
|
||||||
- Taille min/max des impacts
|
efface tous les impacts sans toucher à la calibration
|
||||||
- Circularité minimale
|
|
||||||
- Ratio de remplissage (distingue les trous pleins des cercles vides)
|
|
||||||
- **Détection par références** : sélectionner 2-4 impacts manuellement, l'algorithme apprend leurs caractéristiques et détecte les impacts similaires
|
|
||||||
|
|
||||||
### Calcul des scores
|
### Calcul des scores
|
||||||
- Score automatique basé sur la position de l'impact dans les zones
|
- Score automatique basé sur la position de l'impact dans les zones
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@@ -37,9 +37,6 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<meta-data
|
|
||||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
|
||||||
android:value="docscanner" />
|
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
4
backendia/.gitignore
vendored
@@ -13,8 +13,7 @@ dashboard/out/
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
# Database
|
# Database (contient des données utilisateurs — jamais dans git)
|
||||||
backendia/uploads/data/database.sqlite
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-journal
|
*.sqlite-journal
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ uploads/images/*
|
|||||||
!uploads/images/.gitkeep
|
!uploads/images/.gitkeep
|
||||||
uploads/data/*
|
uploads/data/*
|
||||||
!uploads/data/.gitkeep
|
!uploads/data/.gitkeep
|
||||||
!uploads/data/database.sqlite
|
|
||||||
exports/*
|
exports/*
|
||||||
!exports/.gitkeep
|
!exports/.gitkeep
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777452202935",
|
|
||||||
"timestamp": "2026-04-29T08:43:22.943303",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.3934941044560185,
|
|
||||||
"norm_y": 0.665676540798611
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6055302372685185,
|
|
||||||
"norm_y": 0.6953124999999999
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5981137876157407,
|
|
||||||
"norm_y": 0.5249565972222221
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6759033203125,
|
|
||||||
"norm_y": 0.4786376953125
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.48608398437499994,
|
|
||||||
"norm_y": 0.5490315755208333
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5101625795717593,
|
|
||||||
"norm_y": 0.43238661024305547
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6296079282407407,
|
|
||||||
"norm_y": 0.4203152126736111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.46386718749999994,
|
|
||||||
"norm_y": 0.48046875
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.3379521122685185,
|
|
||||||
"norm_y": 0.4397786458333333
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777452481252",
|
|
||||||
"timestamp": "2026-04-29T08:48:01.259705",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.3934941044560185,
|
|
||||||
"norm_y": 0.665676540798611
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6055302372685185,
|
|
||||||
"norm_y": 0.6953124999999999
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5981137876157407,
|
|
||||||
"norm_y": 0.5249565972222221
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6759033203125,
|
|
||||||
"norm_y": 0.4786376953125
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.48608398437499994,
|
|
||||||
"norm_y": 0.5490315755208333
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5101625795717593,
|
|
||||||
"norm_y": 0.43238661024305547
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6296079282407407,
|
|
||||||
"norm_y": 0.4203152126736111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.46386718749999994,
|
|
||||||
"norm_y": 0.48046875
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.3379521122685185,
|
|
||||||
"norm_y": 0.4397786458333333
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777452613625",
|
|
||||||
"timestamp": "2026-04-29T08:50:13.631206",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 10,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.49810836226851846,
|
|
||||||
"norm_y": 0.4981011284722222
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777463308809",
|
|
||||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
|
||||||
"timestamp": "2026-04-29T11:48:28.834236",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.38424660011574074,
|
|
||||||
"norm_y": 0.3666042751736111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6055302372685185,
|
|
||||||
"norm_y": 0.396240234375
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5860903139467593,
|
|
||||||
"norm_y": 0.5999620225694443
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.33517433449074074,
|
|
||||||
"norm_y": 0.5592041015625
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6073612919560185,
|
|
||||||
"norm_y": 0.5101047092013888
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.432373046875,
|
|
||||||
"norm_y": 0.46663411458333337
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.45181297019675926,
|
|
||||||
"norm_y": 0.5564236111111112
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 10,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5221860532407407,
|
|
||||||
"norm_y": 0.48792860243055547
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6564335575810184,
|
|
||||||
"norm_y": 0.7536349826388888
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 10,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.4712528935185185,
|
|
||||||
"norm_y": 0.7018229166666666
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 11,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.3981327763310185,
|
|
||||||
"norm_y": 0.7018229166666666
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 12,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.48144531249999994,
|
|
||||||
"norm_y": 0.8212483723958334
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777463703756",
|
|
||||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
|
||||||
"timestamp": "2026-04-29T11:55:03.765285",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.38424660011574074,
|
|
||||||
"norm_y": 0.45178222656249994
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6518247251157407,
|
|
||||||
"norm_y": 0.3638237847222222
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5490415219907407,
|
|
||||||
"norm_y": 0.66845703125
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.405517578125,
|
|
||||||
"norm_y": 0.6758490668402777
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7268066406249999,
|
|
||||||
"norm_y": 0.6758490668402777
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5471806278935185,
|
|
||||||
"norm_y": 0.529568142361111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.44720504195601846,
|
|
||||||
"norm_y": 0.5980631510416666
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.45458984375,
|
|
||||||
"norm_y": 0.4221462673611111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5203251591435185,
|
|
||||||
"norm_y": 0.3638237847222222
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777464156234",
|
|
||||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
|
||||||
"timestamp": "2026-04-29T12:02:36.255849",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.38424660011574074,
|
|
||||||
"norm_y": 0.45178222656249994
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6518247251157407,
|
|
||||||
"norm_y": 0.3638237847222222
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5490415219907407,
|
|
||||||
"norm_y": 0.66845703125
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.405517578125,
|
|
||||||
"norm_y": 0.6758490668402777
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7268066406249999,
|
|
||||||
"norm_y": 0.6758490668402777
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5471806278935185,
|
|
||||||
"norm_y": 0.529568142361111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.44720504195601846,
|
|
||||||
"norm_y": 0.5980631510416666
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.45458984375,
|
|
||||||
"norm_y": 0.4221462673611111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5203251591435185,
|
|
||||||
"norm_y": 0.3638237847222222
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777464607256",
|
|
||||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
|
||||||
"timestamp": "2026-04-29T12:10:07.260954",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.31570457175925926,
|
|
||||||
"norm_y": 0.7388509114583333
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.3184814453125,
|
|
||||||
"norm_y": 0.6489935980902778
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.3110966435185185,
|
|
||||||
"norm_y": 0.529568142361111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.31570457175925926,
|
|
||||||
"norm_y": 0.4416097005208333
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 4,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.31570457175925926,
|
|
||||||
"norm_y": 0.34252929687499994
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.32589699074074074,
|
|
||||||
"norm_y": 0.2332763671875
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.45458984375,
|
|
||||||
"norm_y": 0.19624837239583331
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5981137876157407,
|
|
||||||
"norm_y": 0.23049587673611108
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6907045717592593,
|
|
||||||
"norm_y": 0.3546006944444444
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 10,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.699951171875,
|
|
||||||
"norm_y": 0.48046875
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 11,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6268310546874999,
|
|
||||||
"norm_y": 0.5416395399305556
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 12,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.49072265624999994,
|
|
||||||
"norm_y": 0.5610351562499999
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 13,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.4203495732060185,
|
|
||||||
"norm_y": 0.536960177951389
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 14,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 6,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5370180483217593,
|
|
||||||
"norm_y": 0.6879204644097222
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 15,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6175537109375,
|
|
||||||
"norm_y": 0.7601453993055556
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 16,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 1,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7027280454282406,
|
|
||||||
"norm_y": 0.8406439887152777
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 107 KiB |
@@ -1 +0,0 @@
|
|||||||
# Keep directory structure
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777553535029",
|
|
||||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
|
||||||
"timestamp": "2026-04-30T12:52:15.048830",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 0,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.17681929976851848,
|
|
||||||
"norm_y": 0.8851318359375
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 0,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.17681929976851848,
|
|
||||||
"norm_y": 0.7721489800347222
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 1,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.17959617332175923,
|
|
||||||
"norm_y": 0.6888020833333334
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.17959617332175923,
|
|
||||||
"norm_y": 0.6342095269097222
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.17959617332175923,
|
|
||||||
"norm_y": 0.560153537326389
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.17959617332175923,
|
|
||||||
"norm_y": 0.5138346354166666
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.1814579716435185,
|
|
||||||
"norm_y": 0.45273166232638884
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.18701171875,
|
|
||||||
"norm_y": 0.3878987630208333
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 1,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.18887351707175926,
|
|
||||||
"norm_y": 0.2925482855902778
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 10,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 0,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.2036747685185185,
|
|
||||||
"norm_y": 0.20085991753472218
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 11,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 0,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.27035499855324074,
|
|
||||||
"norm_y": 0.16200086805555558
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 12,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 1,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.37588523582175926,
|
|
||||||
"norm_y": 0.1546088324652778
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 13,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.46292136863425926,
|
|
||||||
"norm_y": 0.1518283420138889
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 14,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 1,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6814579716435185,
|
|
||||||
"norm_y": 0.1499294704861111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 15,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5509033203125,
|
|
||||||
"norm_y": 0.1546088324652778
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 16,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 1,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6156919126157407,
|
|
||||||
"norm_y": 0.1546088324652778
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 17,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 1,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7703251591435185,
|
|
||||||
"norm_y": 0.2360568576388889
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 18,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 4,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7110595703125,
|
|
||||||
"norm_y": 0.3536512586805555
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 19,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6027524594907407,
|
|
||||||
"norm_y": 0.4129231770833333
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 20,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.4823911313657407,
|
|
||||||
"norm_y": 0.4304877387152777
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 21,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.39627097800925926,
|
|
||||||
"norm_y": 0.43509928385416663
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 22,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.289794921875,
|
|
||||||
"norm_y": 0.43238661024305547
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 23,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 2,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7166440610532407,
|
|
||||||
"norm_y": 0.7693684895833333
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 24,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 3,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7314453125,
|
|
||||||
"norm_y": 0.7008734809027778
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 25,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 1,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7879331235532407,
|
|
||||||
"norm_y": 0.7601453993055556
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 26,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 0,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.736083984375,
|
|
||||||
"norm_y": 0.828640407986111
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Keep directory structure
|
|
||||||
|
Before Width: | Height: | Size: 105 KiB |
0
backendia/uploads/data/.gitkeep
Normal file
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "session_1777554006515",
|
|
||||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
|
||||||
"timestamp": "2026-04-30T13:00:06.519693",
|
|
||||||
"device_info": {
|
|
||||||
"model": "google sdk_gphone64_x86_64",
|
|
||||||
"os": "Android 16"
|
|
||||||
},
|
|
||||||
"target_metadata": {
|
|
||||||
"type": "concentric",
|
|
||||||
"distance_meters": 25,
|
|
||||||
"weapon": "Unknown"
|
|
||||||
},
|
|
||||||
"plotting": {
|
|
||||||
"target_corners": [
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.09999999999999998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.9,
|
|
||||||
"norm_y": 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"norm_x": 0.09999999999999998,
|
|
||||||
"norm_y": 0.9
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"impacts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 5,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.267578125,
|
|
||||||
"norm_y": 0.5554741753472222
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 7,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5712583188657407,
|
|
||||||
"norm_y": 0.3592800564236111
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 4,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.7555239076967593,
|
|
||||||
"norm_y": 0.5703938802083333
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 4,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.6666259765625,
|
|
||||||
"norm_y": 0.6759168836805556
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 9,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.5490415219907407,
|
|
||||||
"norm_y": 0.5212944878472222
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"label": "bullet_hole",
|
|
||||||
"score": 8,
|
|
||||||
"coords": {
|
|
||||||
"norm_x": 0.4897768373842592,
|
|
||||||
"norm_y": 0.39352756076388895
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
backendia/uploads/images/.gitkeep
Normal file
|
Before Width: | Height: | Size: 107 KiB |
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
|
|||||||
import 'core/theme/theme_provider.dart';
|
import 'core/theme/theme_provider.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'main_navigation_holder.dart';
|
import 'main_navigation_holder.dart';
|
||||||
import 'features/home/home_screen.dart';
|
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
class BullyApp extends StatelessWidget {
|
class BullyApp extends StatelessWidget {
|
||||||
|
|||||||
76
lib/core/widgets/metric_info_button.dart
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/// Bouton d'information (ⓘ) qui explique des métriques à l'utilisateur.
|
||||||
|
///
|
||||||
|
/// Affiche une petite icône cliquable ; au clic, une boîte de dialogue
|
||||||
|
/// détaille la signification de chaque statistique de la carte associée.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Explication d'une métrique : un libellé et sa description.
|
||||||
|
class MetricExplanation {
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
const MetricExplanation(this.label, this.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetricInfoButton extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<MetricExplanation> explanations;
|
||||||
|
|
||||||
|
const MetricInfoButton({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.explanations,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(Icons.info_outline, size: 18, color: Colors.grey[500]),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
tooltip: 'À quoi ça correspond ?',
|
||||||
|
onPressed: () => _showInfo(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showInfo(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final e in explanations)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
e.label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(e.description),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Compris'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
@@ -11,6 +10,12 @@ import '../../core/constants/app_constants.dart';
|
|||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
static DatabaseHelper? _instance;
|
static DatabaseHelper? _instance;
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
// On met en cache le Future d'initialisation (et non la Database résolue)
|
||||||
|
// pour éviter qu'un démarrage concurrent (les 4 onglets de l'IndexedStack
|
||||||
|
// interrogent la base en même temps) ne lance plusieurs _initDatabase() en
|
||||||
|
// parallèle. Sur une base fraîche, cela dédoublait onCreate et rendait la
|
||||||
|
// toute première écriture peu fiable.
|
||||||
|
static Future<Database>? _initFuture;
|
||||||
|
|
||||||
DatabaseHelper._internal();
|
DatabaseHelper._internal();
|
||||||
|
|
||||||
@@ -20,7 +25,9 @@ class DatabaseHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
_database ??= await _initDatabase();
|
if (_database != null) return _database!;
|
||||||
|
_initFuture ??= _initDatabase();
|
||||||
|
_database = await _initFuture!;
|
||||||
return _database!;
|
return _database!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,17 +380,71 @@ class DatabaseHelper {
|
|||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
);
|
);
|
||||||
|
if (sessionMaps.isEmpty) return [];
|
||||||
|
|
||||||
final sessions = <Session>[];
|
// 3 requêtes groupées au lieu d'une cascade par session puis par analyse
|
||||||
for (final sessionMap in sessionMaps) {
|
// (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
|
||||||
final sessionId = sessionMap['id'] as String;
|
// ce qui ralentissait l'accueil/historique/stats au fil des mois.
|
||||||
final session = await getSession(sessionId);
|
final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
|
||||||
if (session != null) {
|
final analysisMaps = await _queryIn(
|
||||||
sessions.add(session);
|
db,
|
||||||
}
|
AppConstants.targetAnalysesTable,
|
||||||
|
'session_id',
|
||||||
|
sessionIds,
|
||||||
|
);
|
||||||
|
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
|
||||||
|
final shotMaps = await _queryIn(
|
||||||
|
db,
|
||||||
|
AppConstants.shotsTable,
|
||||||
|
'analysis_id',
|
||||||
|
analysisIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
final shotsByAnalysis = <String, List<Shot>>{};
|
||||||
|
for (final map in shotMaps) {
|
||||||
|
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
|
||||||
|
.add(Shot.fromMap(map));
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions;
|
final analysesBySession = <String, List<TargetAnalysis>>{};
|
||||||
|
for (final map in analysisMaps) {
|
||||||
|
final analysis = TargetAnalysis.fromMap(
|
||||||
|
map,
|
||||||
|
shotsByAnalysis[map['id'] as String] ?? [],
|
||||||
|
);
|
||||||
|
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionMaps
|
||||||
|
.map((m) =>
|
||||||
|
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
|
||||||
|
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
|
||||||
|
Future<List<Map<String, Object?>>> _queryIn(
|
||||||
|
Database db,
|
||||||
|
String table,
|
||||||
|
String column,
|
||||||
|
List<String> values,
|
||||||
|
) async {
|
||||||
|
if (values.isEmpty) return [];
|
||||||
|
const chunkSize = 500;
|
||||||
|
final results = <Map<String, Object?>>[];
|
||||||
|
for (var i = 0; i < values.length; i += chunkSize) {
|
||||||
|
final chunk = values.sublist(
|
||||||
|
i,
|
||||||
|
i + chunkSize > values.length ? values.length : i + chunkSize,
|
||||||
|
);
|
||||||
|
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||||
|
results.addAll(await db.query(
|
||||||
|
table,
|
||||||
|
where: '$column IN ($placeholders)',
|
||||||
|
whereArgs: chunk,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> deleteSession(String id) async {
|
Future<int> deleteSession(String id) async {
|
||||||
@@ -510,6 +571,16 @@ class DatabaseHelper {
|
|||||||
return Sqflite.firstIntValue(result) ?? 0;
|
return Sqflite.firstIntValue(result) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> getSessionCountForWeapon(String weaponId) async {
|
||||||
|
final db = await database;
|
||||||
|
final result = await db.rawQuery('''
|
||||||
|
SELECT COUNT(id) as count
|
||||||
|
FROM ${AppConstants.sessionsTable}
|
||||||
|
WHERE weapon_id = ?
|
||||||
|
''', [weaponId]);
|
||||||
|
return Sqflite.firstIntValue(result) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> insertMaintenance(MaintenanceEntry entry) async {
|
Future<int> insertMaintenance(MaintenanceEntry entry) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return await db.insert(
|
return await db.insert(
|
||||||
@@ -543,5 +614,6 @@ class DatabaseHelper {
|
|||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
_database = null;
|
_database = null;
|
||||||
|
_initFuture = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,18 +182,23 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> getSessionCountForWeapon(String weaponId) async {
|
||||||
|
return await _databaseHelper.getSessionCountForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> addMaintenanceEntry({
|
Future<void> addMaintenanceEntry({
|
||||||
required String weaponId,
|
required String weaponId,
|
||||||
required MaintenanceType type,
|
required MaintenanceType type,
|
||||||
required String description,
|
required String description,
|
||||||
int? roundsSinceLast,
|
int? roundsSinceLast,
|
||||||
|
DateTime? date,
|
||||||
}) async {
|
}) async {
|
||||||
final entry = MaintenanceEntry(
|
final entry = MaintenanceEntry(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
weaponId: weaponId,
|
weaponId: weaponId,
|
||||||
type: type,
|
type: type,
|
||||||
description: description,
|
description: description,
|
||||||
date: DateTime.now(),
|
date: date ?? DateTime.now(),
|
||||||
roundsSinceLastMaintenance: roundsSinceLast,
|
roundsSinceLastMaintenance: roundsSinceLast,
|
||||||
);
|
);
|
||||||
await _databaseHelper.insertMaintenance(entry);
|
await _databaseHelper.insertMaintenance(entry);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
|
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
|
||||||
///
|
///
|
||||||
/// Gère le workflow complet d'analyse : chargement d'image, détection de cible,
|
/// Gère le workflow complet d'analyse : chargement d'image, gestion des
|
||||||
/// gestion des impacts (manuels et automatiques), calcul des scores,
|
/// impacts placés manuellement, calcul des scores, analyse de groupement
|
||||||
/// analyse de groupement et sauvegarde des sessions.
|
/// et sauvegarde des sessions.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -13,37 +13,25 @@ import '../../data/models/target_analysis.dart';
|
|||||||
import '../../data/models/shot.dart';
|
import '../../data/models/shot.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
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/opencv_target_service.dart';
|
|
||||||
import '../../services/ai_export_service.dart';
|
import '../../services/ai_export_service.dart';
|
||||||
|
|
||||||
enum AnalysisState { initial, loading, success, error }
|
enum AnalysisState { initial, loading, success, error }
|
||||||
|
|
||||||
class AnalysisProvider extends ChangeNotifier {
|
class AnalysisProvider extends ChangeNotifier {
|
||||||
final TargetDetectionService _detectionService;
|
|
||||||
final ScoreCalculatorService _scoreCalculatorService;
|
final ScoreCalculatorService _scoreCalculatorService;
|
||||||
final GroupingAnalyzerService _groupingAnalyzerService;
|
final GroupingAnalyzerService _groupingAnalyzerService;
|
||||||
final SessionRepository _sessionRepository;
|
final SessionRepository _sessionRepository;
|
||||||
final DistortionCorrectionService _distortionService;
|
|
||||||
final OpenCVTargetService _opencvTargetService;
|
|
||||||
final Uuid _uuid = const Uuid();
|
final Uuid _uuid = const Uuid();
|
||||||
|
|
||||||
AnalysisProvider({
|
AnalysisProvider({
|
||||||
required TargetDetectionService detectionService,
|
|
||||||
required ScoreCalculatorService scoreCalculatorService,
|
required ScoreCalculatorService scoreCalculatorService,
|
||||||
required GroupingAnalyzerService groupingAnalyzerService,
|
required GroupingAnalyzerService groupingAnalyzerService,
|
||||||
required SessionRepository sessionRepository,
|
required SessionRepository sessionRepository,
|
||||||
DistortionCorrectionService? distortionService,
|
}) : _scoreCalculatorService = scoreCalculatorService,
|
||||||
OpenCVTargetService? opencvTargetService,
|
|
||||||
}) : _detectionService = detectionService,
|
|
||||||
_scoreCalculatorService = scoreCalculatorService,
|
|
||||||
_groupingAnalyzerService = groupingAnalyzerService,
|
_groupingAnalyzerService = groupingAnalyzerService,
|
||||||
_sessionRepository = sessionRepository,
|
_sessionRepository = sessionRepository;
|
||||||
_distortionService = distortionService ?? DistortionCorrectionService(),
|
|
||||||
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
|
|
||||||
|
|
||||||
AnalysisState _state = AnalysisState.initial;
|
AnalysisState _state = AnalysisState.initial;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
@@ -53,7 +41,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
|
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
|
||||||
double _cropRotation = 0.0;
|
double _cropRotation = 0.0;
|
||||||
|
|
||||||
// Target detection results
|
// Target calibration
|
||||||
double _targetCenterX = 0.5;
|
double _targetCenterX = 0.5;
|
||||||
double _targetCenterY = 0.5;
|
double _targetCenterY = 0.5;
|
||||||
double _targetRadius = 0.4;
|
double _targetRadius = 0.4;
|
||||||
@@ -71,15 +59,6 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
// Grouping results
|
// Grouping results
|
||||||
GroupingResult? _groupingResult;
|
GroupingResult? _groupingResult;
|
||||||
|
|
||||||
// Reference-based detection
|
|
||||||
List<Shot> _referenceImpacts = [];
|
|
||||||
ImpactCharacteristics? _learnedCharacteristics;
|
|
||||||
|
|
||||||
// Distortion correction
|
|
||||||
bool _distortionCorrectionEnabled = false;
|
|
||||||
DistortionParameters? _distortionParams;
|
|
||||||
String? _correctedImagePath;
|
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
AnalysisState get state => _state;
|
AnalysisState get state => _state;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
@@ -100,21 +79,6 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
int get totalScore => _scoreResult?.totalScore ?? 0;
|
int get totalScore => _scoreResult?.totalScore ?? 0;
|
||||||
int get shotCount => _shots.length;
|
int get shotCount => _shots.length;
|
||||||
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
|
|
||||||
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
|
|
||||||
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
|
|
||||||
|
|
||||||
// Distortion correction getters
|
|
||||||
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
|
|
||||||
DistortionParameters? get distortionParams => _distortionParams;
|
|
||||||
String? get correctedImagePath => _correctedImagePath;
|
|
||||||
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
|
|
||||||
|
|
||||||
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
|
|
||||||
String? get displayImagePath =>
|
|
||||||
_distortionCorrectionEnabled && _correctedImagePath != null
|
|
||||||
? _correctedImagePath
|
|
||||||
: _imagePath;
|
|
||||||
|
|
||||||
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
||||||
void setCropRotation(double rotation) {
|
void setCropRotation(double rotation) {
|
||||||
@@ -122,16 +86,13 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Analyze an image
|
/// Charge l'image et initialise les paramètres de cible par défaut.
|
||||||
///
|
/// Le placement des impacts et la calibration se font ensuite manuellement.
|
||||||
/// [autoAnalyze] determines if we should run automatic detection immediately.
|
|
||||||
/// If false, only the image is loaded and default target parameters are set.
|
|
||||||
Future<void> analyzeImage(
|
Future<void> analyzeImage(
|
||||||
String imagePath,
|
String imagePath,
|
||||||
TargetType targetType, {
|
TargetType targetType, {
|
||||||
bool autoAnalyze = true,
|
Offset? manualCenter,
|
||||||
Offset? manualCenter,
|
}) async {
|
||||||
}) async {
|
|
||||||
_state = AnalysisState.loading;
|
_state = AnalysisState.loading;
|
||||||
_imagePath = imagePath;
|
_imagePath = imagePath;
|
||||||
_targetType = targetType;
|
_targetType = targetType;
|
||||||
@@ -147,54 +108,12 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_imageAspectRatio = frame.image.width / frame.image.height;
|
_imageAspectRatio = frame.image.width / frame.image.height;
|
||||||
frame.image.dispose();
|
frame.image.dispose();
|
||||||
|
|
||||||
if (!autoAnalyze) {
|
_targetCenterX = manualCenter?.dx ?? 0.5;
|
||||||
// Just setup default values without running detection
|
_targetCenterY = manualCenter?.dy ?? 0.5;
|
||||||
_targetCenterX = manualCenter?.dx ?? 0.5;
|
_targetRadius = 0.4;
|
||||||
_targetCenterY = manualCenter?.dy ?? 0.5;
|
_targetInnerRadius = 0.04;
|
||||||
_targetRadius = 0.4;
|
|
||||||
_targetInnerRadius = 0.04;
|
|
||||||
|
|
||||||
// Initialize empty shots list
|
_shots = [];
|
||||||
_shots = [];
|
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
|
||||||
notifyListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await _detectionService.detectTargetAsync(
|
|
||||||
imagePath,
|
|
||||||
targetType,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
_state = AnalysisState.error;
|
|
||||||
_errorMessage = result.errorMessage;
|
|
||||||
notifyListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_targetCenterX = result.centerX;
|
|
||||||
_targetCenterY = result.centerY;
|
|
||||||
_targetRadius = result.radius;
|
|
||||||
_targetInnerRadius = result.radius * 0.1;
|
|
||||||
|
|
||||||
// Create shots from detected impacts
|
|
||||||
_shots = result.impacts.map((impact) {
|
|
||||||
return Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: impact.suggestedScore,
|
|
||||||
analysisId: '',
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Calculate scores
|
|
||||||
_recalculateScores();
|
|
||||||
|
|
||||||
// Calculate grouping
|
|
||||||
_recalculateGrouping();
|
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
_state = AnalysisState.success;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -216,22 +135,18 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a shot
|
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Plotting).
|
||||||
void removeShot(String shotId) {
|
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
|
||||||
_shots.removeWhere((shot) => shot.id == shotId);
|
void clearShots() {
|
||||||
|
_shots.clear();
|
||||||
_recalculateScores();
|
_recalculateScores();
|
||||||
_recalculateGrouping();
|
_recalculateGrouping();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a shot to a new position
|
/// Remove a shot
|
||||||
void moveShot(String shotId, double newX, double newY) {
|
void removeShot(String shotId) {
|
||||||
final index = _shots.indexWhere((shot) => shot.id == shotId);
|
_shots.removeWhere((shot) => shot.id == shotId);
|
||||||
if (index == -1) return;
|
|
||||||
|
|
||||||
final newScore = _calculateShotScore(newX, newY);
|
|
||||||
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
|
|
||||||
|
|
||||||
_recalculateScores();
|
_recalculateScores();
|
||||||
_recalculateGrouping();
|
_recalculateGrouping();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -247,276 +162,17 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auto-detect impacts using image processing
|
|
||||||
Future<int> autoDetectImpacts({
|
|
||||||
int darkThreshold = 80,
|
|
||||||
int minImpactSize = 20,
|
|
||||||
int maxImpactSize = 500,
|
|
||||||
double minCircularity = 0.6,
|
|
||||||
double minFillRatio = 0.5,
|
|
||||||
bool clearExisting = false,
|
|
||||||
}) async {
|
|
||||||
if (_imagePath == null || _targetType == null) return 0;
|
|
||||||
|
|
||||||
final settings = ImpactDetectionSettings(
|
|
||||||
darkThreshold: darkThreshold,
|
|
||||||
minImpactSize: minImpactSize,
|
|
||||||
maxImpactSize: maxImpactSize,
|
|
||||||
minCircularity: minCircularity,
|
|
||||||
minFillRatio: minFillRatio,
|
|
||||||
);
|
|
||||||
|
|
||||||
final detectedImpacts = _detectionService.detectImpactsOnly(
|
|
||||||
_imagePath!,
|
|
||||||
_targetType!,
|
|
||||||
_targetCenterX,
|
|
||||||
_targetCenterY,
|
|
||||||
_targetRadius,
|
|
||||||
_ringCount,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clearExisting) {
|
|
||||||
_shots.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detected impacts as shots
|
|
||||||
for (final impact in detectedImpacts) {
|
|
||||||
final score = _calculateShotScore(impact.x, impact.y);
|
|
||||||
final shot = Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: score,
|
|
||||||
analysisId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
|
|
||||||
Future<int> autoDetectImpactsWithOpenCV({
|
|
||||||
double cannyThreshold1 = 50,
|
|
||||||
double cannyThreshold2 = 150,
|
|
||||||
double minDist = 20,
|
|
||||||
double param1 = 100,
|
|
||||||
double param2 = 30,
|
|
||||||
int minRadius = 5,
|
|
||||||
int maxRadius = 50,
|
|
||||||
int minSize = 5,
|
|
||||||
int maxSize = 1000,
|
|
||||||
int blurSize = 5,
|
|
||||||
bool useContourDetection = true,
|
|
||||||
double minCircularity = 0.6,
|
|
||||||
double minContourArea = 50,
|
|
||||||
double maxContourArea = 5000,
|
|
||||||
bool clearExisting = false,
|
|
||||||
}) async {
|
|
||||||
if (_imagePath == null || _targetType == null) return 0;
|
|
||||||
|
|
||||||
final settings = OpenCVDetectionSettings(
|
|
||||||
cannyThreshold1: cannyThreshold1,
|
|
||||||
cannyThreshold2: cannyThreshold2,
|
|
||||||
minDist: minDist,
|
|
||||||
param1: param1,
|
|
||||||
param2: param2,
|
|
||||||
minRadius: minRadius,
|
|
||||||
maxRadius: maxRadius,
|
|
||||||
blurSize: blurSize,
|
|
||||||
useContourDetection: useContourDetection,
|
|
||||||
minCircularity: minCircularity,
|
|
||||||
minContourArea: minContourArea,
|
|
||||||
maxContourArea: maxContourArea,
|
|
||||||
);
|
|
||||||
|
|
||||||
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
|
|
||||||
_imagePath!,
|
|
||||||
_targetType!,
|
|
||||||
_targetCenterX,
|
|
||||||
_targetCenterY,
|
|
||||||
_targetRadius,
|
|
||||||
_ringCount,
|
|
||||||
settings: settings,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clearExisting) {
|
|
||||||
_shots.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detected impacts as shots
|
|
||||||
for (final impact in detectedImpacts) {
|
|
||||||
final score = _calculateShotScore(impact.x, impact.y);
|
|
||||||
final shot = Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: score,
|
|
||||||
analysisId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect impacts with OpenCV using reference points
|
|
||||||
Future<int> detectFromReferencesWithOpenCV({
|
|
||||||
double tolerance = 2.0,
|
|
||||||
bool clearExisting = false,
|
|
||||||
}) async {
|
|
||||||
if (_imagePath == null ||
|
|
||||||
_targetType == null ||
|
|
||||||
_referenceImpacts.length < 2) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convertir les références
|
|
||||||
final references = _referenceImpacts
|
|
||||||
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final detectedImpacts = _detectionService
|
|
||||||
.detectImpactsWithOpenCVFromReferences(
|
|
||||||
_imagePath!,
|
|
||||||
_targetType!,
|
|
||||||
_targetCenterX,
|
|
||||||
_targetCenterY,
|
|
||||||
_targetRadius,
|
|
||||||
_ringCount,
|
|
||||||
references,
|
|
||||||
tolerance: tolerance,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clearExisting) {
|
|
||||||
_shots.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detected impacts as shots
|
|
||||||
for (final impact in detectedImpacts) {
|
|
||||||
final score = _calculateShotScore(impact.x, impact.y);
|
|
||||||
final shot = Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: score,
|
|
||||||
analysisId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a reference impact for calibrated detection
|
|
||||||
void addReferenceImpact(double x, double y) {
|
|
||||||
final score = _calculateShotScore(x, y);
|
|
||||||
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
|
|
||||||
_referenceImpacts.add(shot);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a reference impact
|
|
||||||
void removeReferenceImpact(String shotId) {
|
|
||||||
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
|
|
||||||
_learnedCharacteristics = null;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all reference impacts
|
|
||||||
void clearReferenceImpacts() {
|
|
||||||
_referenceImpacts.clear();
|
|
||||||
_learnedCharacteristics = null;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Learn characteristics from reference impacts
|
|
||||||
bool learnFromReferences() {
|
|
||||||
if (_imagePath == null || _referenceImpacts.length < 2) return false;
|
|
||||||
|
|
||||||
final references = _referenceImpacts
|
|
||||||
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
|
|
||||||
_imagePath!,
|
|
||||||
references,
|
|
||||||
);
|
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
return _learnedCharacteristics != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto-detect impacts using learned reference characteristics
|
|
||||||
Future<int> detectFromReferences({
|
|
||||||
double tolerance = 2.0,
|
|
||||||
bool clearExisting = false,
|
|
||||||
}) async {
|
|
||||||
if (_imagePath == null ||
|
|
||||||
_targetType == null ||
|
|
||||||
_learnedCharacteristics == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
final detectedImpacts = _detectionService.detectImpactsFromReferences(
|
|
||||||
_imagePath!,
|
|
||||||
_targetType!,
|
|
||||||
_targetCenterX,
|
|
||||||
_targetCenterY,
|
|
||||||
_targetRadius,
|
|
||||||
_ringCount,
|
|
||||||
_learnedCharacteristics!,
|
|
||||||
tolerance: tolerance,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clearExisting) {
|
|
||||||
_shots.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detected impacts as shots
|
|
||||||
for (final impact in detectedImpacts) {
|
|
||||||
final score = _calculateShotScore(impact.x, impact.y);
|
|
||||||
final shot = Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: score,
|
|
||||||
analysisId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjust target position
|
/// Adjust target position
|
||||||
void adjustTargetPosition(
|
void adjustTargetPosition(
|
||||||
double centerX,
|
double centerX,
|
||||||
double centerY,
|
double centerY,
|
||||||
double innerRadius,
|
double innerRadius,
|
||||||
double radius, {
|
double radius, {
|
||||||
int? ringCount,
|
int? ringCount,
|
||||||
List<double>? ringRadii,
|
List<double>? ringRadii,
|
||||||
double zoomScale = 1.0,
|
double zoomScale = 1.0,
|
||||||
Offset offset = Offset.zero,
|
Offset offset = Offset.zero,
|
||||||
}) {
|
}) {
|
||||||
_targetCenterX = (centerX - offset.dx) / zoomScale;
|
_targetCenterX = (centerX - offset.dx) / zoomScale;
|
||||||
_targetCenterY = (centerY - offset.dy) / zoomScale;
|
_targetCenterY = (centerY - offset.dy) / zoomScale;
|
||||||
_targetRadius = radius / zoomScale;
|
_targetRadius = radius / zoomScale;
|
||||||
@@ -539,118 +195,6 @@ 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;
|
|
||||||
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) {
|
|
||||||
debugPrint('Auto-calibration error: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
|
|
||||||
void calculateDistortion() {
|
|
||||||
_distortionParams = _distortionService.calculateDistortionFromCalibration(
|
|
||||||
targetCenterX: _targetCenterX,
|
|
||||||
targetCenterY: _targetCenterY,
|
|
||||||
targetRadius: _targetRadius,
|
|
||||||
imageAspectRatio: _imageAspectRatio,
|
|
||||||
);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applique la correction de distorsion à l'image
|
|
||||||
/// Crée une nouvelle image corrigée et la sauvegarde
|
|
||||||
Future<void> applyDistortionCorrection() async {
|
|
||||||
if (_imagePath == null || _distortionParams == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_correctedImagePath = await _distortionService.applyCorrection(
|
|
||||||
_imagePath!,
|
|
||||||
_distortionParams!,
|
|
||||||
);
|
|
||||||
_distortionCorrectionEnabled = true;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
_errorMessage = 'Erreur lors de la correction: $e';
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active ou désactive l'affichage de l'image corrigée
|
|
||||||
void setDistortionCorrectionEnabled(bool enabled) {
|
|
||||||
if (enabled && _correctedImagePath == null && _distortionParams != null) {
|
|
||||||
// Si on active mais pas encore d'image corrigée, la créer
|
|
||||||
applyDistortionCorrection();
|
|
||||||
} else {
|
|
||||||
_distortionCorrectionEnabled = enabled;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calcule ET applique la correction pour un feedback immédiat
|
|
||||||
Future<void> calculateAndApplyDistortion() async {
|
|
||||||
// 1. Calcul des paramètres (votre code actuel)
|
|
||||||
_distortionParams = _distortionService.calculateDistortionFromCalibration(
|
|
||||||
targetCenterX: _targetCenterX,
|
|
||||||
targetCenterY: _targetCenterY,
|
|
||||||
targetRadius: _targetRadius,
|
|
||||||
imageAspectRatio: _imageAspectRatio,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Vérification si une correction est réellement nécessaire
|
|
||||||
if (_distortionParams != null && _distortionParams!.needsCorrection) {
|
|
||||||
// 3. Application immédiate de la transformation (méthode asynchrone)
|
|
||||||
await applyDistortionCorrection();
|
|
||||||
} else {
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> runFullDistortionWorkflow() async {
|
|
||||||
_state = AnalysisState.loading;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
calculateDistortion();
|
|
||||||
await applyDistortionCorrection();
|
|
||||||
_distortionCorrectionEnabled = true;
|
|
||||||
_state = AnalysisState.success;
|
|
||||||
} catch (e) {
|
|
||||||
_errorMessage = "Erreur de rendu : $e";
|
|
||||||
_state = AnalysisState.error;
|
|
||||||
} finally {
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int _calculateShotScore(double x, double y) {
|
int _calculateShotScore(double x, double y) {
|
||||||
if (_targetType == TargetType.concentric) {
|
if (_targetType == TargetType.concentric) {
|
||||||
return _scoreCalculatorService.calculateConcentricScore(
|
return _scoreCalculatorService.calculateConcentricScore(
|
||||||
@@ -694,8 +238,14 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
|
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exporte l'image et le json vers le backend IA
|
/// Exporte l'image et le json vers le backend IA.
|
||||||
Future<bool> exportToAiBackend() async {
|
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
|
||||||
|
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
|
||||||
|
Future<bool> exportToAiBackend({
|
||||||
|
String? sessionId,
|
||||||
|
int? distance,
|
||||||
|
String? weapon,
|
||||||
|
}) async {
|
||||||
if (_imagePath == null || _targetType == null) {
|
if (_imagePath == null || _targetType == null) {
|
||||||
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
|
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -709,12 +259,14 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
final success = await service.exportData(
|
final success = await service.exportData(
|
||||||
imagePath: _imagePath!,
|
imagePath: _imagePath!,
|
||||||
sessionId: 'export',
|
sessionId: sessionId ?? 'export',
|
||||||
targetType: _targetType!,
|
targetType: _targetType!,
|
||||||
targetCenterX: _targetCenterX,
|
targetCenterX: _targetCenterX,
|
||||||
targetCenterY: _targetCenterY,
|
targetCenterY: _targetCenterY,
|
||||||
targetRadius: _targetRadius,
|
targetRadius: _targetRadius,
|
||||||
shots: _shots,
|
shots: _shots,
|
||||||
|
distanceMeters: distance ?? 25,
|
||||||
|
weaponName: weapon ?? 'Unknown',
|
||||||
);
|
);
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
_state = AnalysisState.success;
|
||||||
@@ -807,11 +359,6 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_shots = [];
|
_shots = [];
|
||||||
_scoreResult = null;
|
_scoreResult = null;
|
||||||
_groupingResult = null;
|
_groupingResult = null;
|
||||||
_referenceImpacts = [];
|
|
||||||
_learnedCharacteristics = null;
|
|
||||||
_distortionCorrectionEnabled = false;
|
|
||||||
_distortionParams = null;
|
|
||||||
_correctedImagePath = null;
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,27 +12,24 @@ import 'package:provider/provider.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 '../../data/models/shot.dart';
|
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
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/wallet_identity_service.dart';
|
||||||
import '../session/session_provider.dart';
|
import '../session/session_provider.dart';
|
||||||
import 'analysis_provider.dart';
|
import 'analysis_provider.dart';
|
||||||
|
import 'impact_editor_screen.dart';
|
||||||
import '../crop/crop_screen.dart';
|
import '../crop/crop_screen.dart';
|
||||||
import '../capture/capture_screen.dart';
|
import '../capture/capture_screen.dart';
|
||||||
import 'widgets/target_overlay.dart';
|
import 'widgets/target_overlay.dart';
|
||||||
import 'widgets/target_calibration.dart';
|
import 'widgets/target_calibration.dart';
|
||||||
import 'widgets/score_card.dart';
|
import 'widgets/score_card.dart';
|
||||||
import 'widgets/grouping_stats.dart';
|
import 'widgets/grouping_stats.dart';
|
||||||
|
import 'widgets/shot_details_sheet.dart';
|
||||||
|
|
||||||
class AnalysisScreen extends StatelessWidget {
|
class AnalysisScreen extends StatelessWidget {
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
|
final String? originalImagePath; // AJOUT : image source avant le crop à 85%
|
||||||
/// Image originale (jamais recadrée), transmise pour pouvoir revenir au
|
|
||||||
/// centrage sans recadrer un résultat déjà recadré.
|
|
||||||
final String? originalImagePath;
|
|
||||||
|
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
final double? initialCenterX;
|
final double? initialCenterX;
|
||||||
final double? initialCenterY;
|
final double? initialCenterY;
|
||||||
@@ -43,7 +40,7 @@ class AnalysisScreen extends StatelessWidget {
|
|||||||
const AnalysisScreen({
|
const AnalysisScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imagePath,
|
required this.imagePath,
|
||||||
this.originalImagePath,
|
this.originalImagePath, // AJOUT
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
this.initialCenterX,
|
this.initialCenterX,
|
||||||
this.initialCenterY,
|
this.initialCenterY,
|
||||||
@@ -63,7 +60,6 @@ class AnalysisScreen extends StatelessWidget {
|
|||||||
return ChangeNotifierProvider(
|
return ChangeNotifierProvider(
|
||||||
create: (context) {
|
create: (context) {
|
||||||
final p = AnalysisProvider(
|
final p = AnalysisProvider(
|
||||||
detectionService: context.read<TargetDetectionService>(),
|
|
||||||
scoreCalculatorService: context.read<ScoreCalculatorService>(),
|
scoreCalculatorService: context.read<ScoreCalculatorService>(),
|
||||||
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
|
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
|
||||||
sessionRepository: context.read<SessionRepository>(),
|
sessionRepository: context.read<SessionRepository>(),
|
||||||
@@ -75,13 +71,12 @@ class AnalysisScreen extends StatelessWidget {
|
|||||||
p.analyzeImage(
|
p.analyzeImage(
|
||||||
imagePath,
|
imagePath,
|
||||||
targetType,
|
targetType,
|
||||||
autoAnalyze: false,
|
|
||||||
manualCenter: manualCenterOffset,
|
manualCenter: manualCenterOffset,
|
||||||
);
|
);
|
||||||
return p;
|
return p;
|
||||||
},
|
},
|
||||||
child: _AnalysisScreenContent(
|
child: _AnalysisScreenContent(
|
||||||
originalImagePath: originalImagePath ?? imagePath,
|
originalImagePath: originalImagePath, // AJOUT
|
||||||
cropScale: cropScale,
|
cropScale: cropScale,
|
||||||
cropOffset: cropOffset,
|
cropOffset: cropOffset,
|
||||||
cropRotation: cropRotation, // Envoyé à la structure d'affichage
|
cropRotation: cropRotation, // Envoyé à la structure d'affichage
|
||||||
@@ -91,13 +86,13 @@ class AnalysisScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AnalysisScreenContent extends StatefulWidget {
|
class _AnalysisScreenContent extends StatefulWidget {
|
||||||
final String originalImagePath;
|
final String? originalImagePath; // AJOUT
|
||||||
final double? cropScale;
|
final double? cropScale;
|
||||||
final Offset? cropOffset;
|
final Offset? cropOffset;
|
||||||
final double? cropRotation;
|
final double? cropRotation;
|
||||||
|
|
||||||
const _AnalysisScreenContent({
|
const _AnalysisScreenContent({
|
||||||
required this.originalImagePath,
|
this.originalImagePath, // AJOUT
|
||||||
this.cropScale,
|
this.cropScale,
|
||||||
this.cropOffset,
|
this.cropOffset,
|
||||||
this.cropRotation,
|
this.cropRotation,
|
||||||
@@ -113,7 +108,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
|
|
||||||
// Forcé à TRUE pour démarrer sur l'ajustement des cercles
|
// Forcé à TRUE pour démarrer sur l'ajustement des cercles
|
||||||
bool _isCalibrating = true;
|
bool _isCalibrating = true;
|
||||||
bool _isSelectingReferences = false;
|
|
||||||
bool _isAtBottom = false;
|
bool _isAtBottom = false;
|
||||||
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
@@ -121,7 +115,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
TransformationController();
|
TransformationController();
|
||||||
final GlobalKey _imageKey = GlobalKey();
|
final GlobalKey _imageKey = GlobalKey();
|
||||||
double _currentZoomScale = 1.0;
|
double _currentZoomScale = 1.0;
|
||||||
String? _movingShotId;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -160,20 +153,55 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Revient au Centrage en repartant de l'image ORIGINALE.
|
/// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer.
|
||||||
/// On ne restaure que la rotation (jamais le zoom) → pas de cumul.
|
///
|
||||||
void _goBackToCrop(AnalysisProvider provider) {
|
/// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting
|
||||||
Navigator.pushReplacement(
|
/// persiste dans le TransformationController et se réapplique au retour,
|
||||||
context,
|
/// ce qui faisait "zoomer" légèrement la photo. On repart donc toujours
|
||||||
|
/// d'une transformation identité (zoom 1.0).
|
||||||
|
void _enterCalibration() {
|
||||||
|
_transformationController.value = Matrix4.identity();
|
||||||
|
_currentZoomScale = 1.0;
|
||||||
|
setState(() => _isCalibrating = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant.
|
||||||
|
///
|
||||||
|
/// On utilise ChangeNotifierProvider.value pour que l'éditeur lise et modifie
|
||||||
|
/// exactement le même AnalysisProvider que cet écran : les impacts ajoutés,
|
||||||
|
/// déplacés ou supprimés sont donc immédiatement répercutés ici.
|
||||||
|
///
|
||||||
|
/// Au retour : si l'utilisateur a validé (résultat true) on bascule en mode
|
||||||
|
/// Plotting (lecture seule) ; sinon on repasse en calibration.
|
||||||
|
Future<void> _openImpactEditor(AnalysisProvider provider) async {
|
||||||
|
final validated = await Navigator.of(context).push<bool>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => CropScreen(
|
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
|
||||||
imagePath: widget.originalImagePath,
|
value: provider,
|
||||||
originalImagePath: widget.originalImagePath,
|
child: const ImpactEditorScreen(),
|
||||||
targetType: provider.targetType!,
|
|
||||||
initialRotation: provider.cropRotation,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (validated == true) {
|
||||||
|
setState(() {
|
||||||
|
_isCalibrating = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_enterCalibration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
|
||||||
|
///
|
||||||
|
/// On privilégie TOUJOURS l'image source non rognée (originalImagePath).
|
||||||
|
/// Repartir de l'image déjà croppée à 85% provoquait un rognage cumulatif
|
||||||
|
/// (0.85 x 0.85 x ...) qui re-zoomait la photo à chaque aller-retour
|
||||||
|
/// entre la capture/crop et la calibration.
|
||||||
|
String _backCropImagePath(AnalysisProvider provider) {
|
||||||
|
return widget.originalImagePath ?? provider.imagePath!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -197,26 +225,99 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_isCalibrating) {
|
if (_isCalibrating) {
|
||||||
final provider = context.read<AnalysisProvider>();
|
final provider = context.read<AnalysisProvider>();
|
||||||
_goBackToCrop(provider);
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => CropScreen(
|
||||||
|
// CORRECTION : on repart de l'image SOURCE (non rognée) pour
|
||||||
|
// éviter le rognage cumulatif à 85% qui re-zoomait à chaque retour.
|
||||||
|
imagePath: _backCropImagePath(provider),
|
||||||
|
targetType: provider.targetType!,
|
||||||
|
initialScale: widget.cropScale,
|
||||||
|
initialOffset: widget.cropOffset,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setState(() => _isCalibrating = true);
|
// Retour Plotting -> Calibration : on réinitialise le zoom.
|
||||||
|
_enterCalibration();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_isCalibrating && !_isSelectingReferences)
|
// Remise à zéro des impacts : efface tous les impacts en un clic,
|
||||||
|
// sans modifier la calibration (centre, rayon, anneaux).
|
||||||
|
if (!_isCalibrating)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
onPressed: () => provider.analyzeImage(
|
tooltip: 'Effacer tous les impacts',
|
||||||
context.read<AnalysisProvider>().imagePath!,
|
onPressed: () => provider.clearShots(),
|
||||||
context.read<AnalysisProvider>().targetType!,
|
),
|
||||||
),
|
// Nuage d'export vers le backend IA : visible uniquement si l'analyse
|
||||||
|
// a réussi ET que l'utilisateur a activé l'option dans les Paramètres.
|
||||||
|
if (!_isCalibrating)
|
||||||
|
FutureBuilder<bool>(
|
||||||
|
future: WalletIdentityService().isUploadEnabled(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final isEnabled = snapshot.data ?? false;
|
||||||
|
if (!isEnabled ||
|
||||||
|
provider.state != AnalysisState.success) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.cloud_upload),
|
||||||
|
tooltip: 'Exporter pour IA',
|
||||||
|
onPressed: () async {
|
||||||
|
final p = context.read<AnalysisProvider>();
|
||||||
|
if (p.state != AnalysisState.success) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Exportation en cours...')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Métadonnées réelles de la session en cours (distance,
|
||||||
|
// arme) plutôt que les placeholders par défaut.
|
||||||
|
final sp = context.read<SessionProvider>();
|
||||||
|
final success = await p.exportToAiBackend(
|
||||||
|
sessionId: sp.activeSessionId,
|
||||||
|
distance: sp.distance,
|
||||||
|
weapon: sp.currentWeapon,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
if (success) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Export réussi vers le backend IA !'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content:
|
||||||
|
Text(p.errorMessage ?? 'Erreur d\'export'),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
if (_isCalibrating)
|
if (_isCalibrating)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() => _isCalibrating = false),
|
onPressed: () {
|
||||||
|
// On fige la calibration courante AVANT d'ouvrir l'éditeur,
|
||||||
|
// puis on passe sur l'écran d'édition d'impacts plein écran
|
||||||
|
// (zoom fiable + placement). Le mode Plotting (lecture seule)
|
||||||
|
// s'affichera au retour si l'utilisateur valide.
|
||||||
|
_calibrationKey.currentState?.commitCalibration();
|
||||||
|
_openImpactEditor(context.read<AnalysisProvider>());
|
||||||
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'TERMINER',
|
'VALIDER',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -263,7 +364,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
widget.cropOffset?.dy ?? 0.0,
|
widget.cropOffset?.dy ?? 0.0,
|
||||||
0.0,
|
0.0,
|
||||||
)
|
)
|
||||||
..scale(1.0, 1.0)
|
|
||||||
..rotateZ(
|
..rotateZ(
|
||||||
(provider.cropRotation) *
|
(provider.cropRotation) *
|
||||||
(math.pi / 180),
|
(math.pi / 180),
|
||||||
@@ -308,7 +408,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: _buildZoomableImageWithOverlay(context, provider),
|
: _buildReadOnlyPlotImage(context, provider),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (!_isCalibrating)
|
if (!_isCalibrating)
|
||||||
@@ -316,6 +416,27 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
Card(
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.18),
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.edit_location_alt,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
title: const Text('Modifier les impacts'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Ajouter, déplacer ou supprimer des impacts (plein écran)',
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.open_in_full,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
// Rouvre l'éditeur plein écran en partageant le provider.
|
||||||
|
onTap: () =>
|
||||||
|
_openImpactEditor(context.read<AnalysisProvider>()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Card(
|
Card(
|
||||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@@ -331,7 +452,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
Icons.arrow_forward_ios,
|
Icons.arrow_forward_ios,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
onTap: () => setState(() => _isCalibrating = true),
|
// Retour vers la calibration : on réinitialise le zoom.
|
||||||
|
onTap: () => _enterCalibration(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -349,8 +471,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
targetCenterX: provider.targetCenterX,
|
targetCenterX: provider.targetCenterX,
|
||||||
targetCenterY: provider.targetCenterY,
|
targetCenterY: provider.targetCenterY,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildActionButtons(context, provider),
|
|
||||||
const SizedBox(height: 50),
|
const SizedBox(height: 50),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -484,7 +604,13 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildZoomableImageWithOverlay(
|
/// Affichage du plotting en LECTURE SEULE.
|
||||||
|
///
|
||||||
|
/// L'édition (ajout / déplacement / suppression) se fait désormais
|
||||||
|
/// exclusivement dans l'éditeur plein écran (ImpactEditorScreen). Ici on se
|
||||||
|
/// contente d'afficher l'image + l'overlay, avec un zoom de consultation.
|
||||||
|
/// Le tap sur un impact ouvre simplement ses détails.
|
||||||
|
Widget _buildReadOnlyPlotImage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
AnalysisProvider provider,
|
AnalysisProvider provider,
|
||||||
) {
|
) {
|
||||||
@@ -492,204 +618,30 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
transformationController: _transformationController,
|
transformationController: _transformationController,
|
||||||
minScale: 1.0,
|
minScale: 1.0,
|
||||||
maxScale: 10.0,
|
maxScale: 10.0,
|
||||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
boundaryMargin: const EdgeInsets.all(80),
|
||||||
panEnabled: _movingShotId == null,
|
panEnabled: true,
|
||||||
child: Transform(
|
child: Stack(
|
||||||
transform: Matrix4.identity()
|
children: [
|
||||||
..setTranslationRaw(
|
Image.file(
|
||||||
widget.cropOffset?.dx ?? 0.0,
|
File(provider.imagePath!),
|
||||||
widget.cropOffset?.dy ?? 0.0,
|
key: _imageKey,
|
||||||
0.0,
|
fit: BoxFit.contain,
|
||||||
)
|
|
||||||
..scale(1.0, 1.0)
|
|
||||||
..rotateZ((provider.cropRotation) * (math.pi / 180)),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: GestureDetector(
|
|
||||||
onDoubleTapDown: (TapDownDetails details) {
|
|
||||||
final RenderBox? box =
|
|
||||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
|
||||||
if (box == null) return;
|
|
||||||
|
|
||||||
final localOffset = box.globalToLocal(details.globalPosition);
|
|
||||||
final relX = localOffset.dx / box.size.width;
|
|
||||||
final relY = localOffset.dy / box.size.height;
|
|
||||||
|
|
||||||
if (provider.shots.isEmpty) return;
|
|
||||||
|
|
||||||
Shot? closestShot;
|
|
||||||
double minDistance = double.infinity;
|
|
||||||
const double clickTolerance = 0.05;
|
|
||||||
|
|
||||||
for (final shot in provider.shots) {
|
|
||||||
final dx = shot.x - relX;
|
|
||||||
final dy = shot.y - relY;
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < minDistance && distance < clickTolerance) {
|
|
||||||
minDistance = distance;
|
|
||||||
closestShot = shot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (closestShot != null) {
|
|
||||||
_showShotDetails(context, provider, closestShot);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPressStart: (LongPressStartDetails details) {
|
|
||||||
final RenderBox? box =
|
|
||||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
|
||||||
if (box == null) return;
|
|
||||||
|
|
||||||
final localOffset = box.globalToLocal(details.globalPosition);
|
|
||||||
final relX = localOffset.dx / box.size.width;
|
|
||||||
final relY = localOffset.dy / box.size.height;
|
|
||||||
|
|
||||||
if (provider.shots.isEmpty) return;
|
|
||||||
|
|
||||||
Shot? closestShot;
|
|
||||||
double minDistance = double.infinity;
|
|
||||||
const double dragTolerance = 0.06;
|
|
||||||
|
|
||||||
for (final shot in provider.shots) {
|
|
||||||
final dx = shot.x - relX;
|
|
||||||
final dy = shot.y - relY;
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < minDistance && distance < dragTolerance) {
|
|
||||||
minDistance = distance;
|
|
||||||
closestShot = shot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (closestShot != null) {
|
|
||||||
setState(() {
|
|
||||||
_movingShotId = closestShot!.id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) {
|
|
||||||
if (_movingShotId == null) return;
|
|
||||||
|
|
||||||
final RenderBox? box =
|
|
||||||
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
|
||||||
if (box == null) return;
|
|
||||||
|
|
||||||
final adjustedGlobalPosition =
|
|
||||||
details.globalPosition + const Offset(-25, -35);
|
|
||||||
final localOffset = box.globalToLocal(adjustedGlobalPosition);
|
|
||||||
|
|
||||||
final relX = (localOffset.dx / box.size.width).clamp(0.0, 1.0);
|
|
||||||
final relY = (localOffset.dy / box.size.height).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
provider.updateShotPosition(_movingShotId!, relX, relY);
|
|
||||||
},
|
|
||||||
onLongPressEnd: (_) {
|
|
||||||
if (_movingShotId != null) {
|
|
||||||
setState(() {
|
|
||||||
_movingShotId = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Image.file(
|
|
||||||
File(provider.imagePath!),
|
|
||||||
key: _imageKey,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
),
|
|
||||||
TargetOverlay(
|
|
||||||
targetCenterX: provider.targetCenterX,
|
|
||||||
targetCenterY: provider.targetCenterY,
|
|
||||||
targetRadius: provider.targetRadius,
|
|
||||||
targetType: provider.targetType!,
|
|
||||||
shots: provider.shots,
|
|
||||||
showRings: true,
|
|
||||||
zoomScale: _currentZoomScale,
|
|
||||||
onShotTapped: (shot) =>
|
|
||||||
_showShotDetails(context, provider, shot),
|
|
||||||
onAddShot: (relX, relY) => provider.addShot(relX, relY),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
Positioned.fill(
|
||||||
),
|
child: TargetOverlay(
|
||||||
);
|
targetCenterX: provider.targetCenterX,
|
||||||
}
|
targetCenterY: provider.targetCenterY,
|
||||||
|
targetRadius: provider.targetRadius,
|
||||||
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
|
targetType: provider.targetType!,
|
||||||
return const Column(children: [Row(children: [])]);
|
shots: provider.shots,
|
||||||
}
|
showRings: true,
|
||||||
|
zoomScale: _currentZoomScale,
|
||||||
void _showShotDetails(
|
// Lecture seule : tap sur impact -> détails (consultation).
|
||||||
BuildContext context,
|
onShotTapped: (shot) =>
|
||||||
AnalysisProvider provider,
|
showShotDetailsSheet(context, provider, shot),
|
||||||
Shot shot,
|
|
||||||
) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Impact #${provider.shots.indexOf(shot) + 1}',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
|
||||||
),
|
),
|
||||||
Text(
|
),
|
||||||
'ID: ${shot.id}',
|
],
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.score),
|
|
||||||
title: const Text('Modifier le score'),
|
|
||||||
trailing: DropdownButton<int>(
|
|
||||||
value: shot.score.clamp(0, 10),
|
|
||||||
items: List.generate(11, (index) => index)
|
|
||||||
.map(
|
|
||||||
(s) => DropdownMenuItem(
|
|
||||||
value: s,
|
|
||||||
child: Text(
|
|
||||||
'$s',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (newScore) {
|
|
||||||
if (newScore != null) {
|
|
||||||
provider.updateShotScore(shot.id, newScore);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
provider.removeShot(shot.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
|
||||||
label: const Text(
|
|
||||||
'SUPPRIMER',
|
|
||||||
style: TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -713,18 +665,18 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
// CORRECTION : on repart aussi de l'image SOURCE non rognée ici.
|
||||||
|
final path = _backCropImagePath(provider);
|
||||||
final type = provider.targetType!;
|
final type = provider.targetType!;
|
||||||
|
|
||||||
// Retour au centrage : on repart de l'ORIGINAL (pas de re-crop),
|
|
||||||
// en conservant uniquement la rotation.
|
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => CropScreen(
|
builder: (context) => CropScreen(
|
||||||
imagePath: widget.originalImagePath,
|
imagePath: path,
|
||||||
originalImagePath: widget.originalImagePath,
|
|
||||||
targetType: type,
|
targetType: type,
|
||||||
initialRotation: provider.cropRotation,
|
initialScale: widget.cropScale,
|
||||||
|
initialOffset: widget.cropOffset,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
209
lib/features/analysis/impact_editor_screen.dart
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/// Écran d'édition des impacts — PLEIN ÉCRAN dédié au zoom et au placement.
|
||||||
|
///
|
||||||
|
/// Cet écran est volontairement minimal : un Scaffold dont le body est
|
||||||
|
/// directement un InteractiveViewer (sans SingleScrollView ni AspectRatio
|
||||||
|
/// contraint autour). C'est la configuration la plus fiable pour le pinch :
|
||||||
|
/// l'InteractiveViewer reçoit les deux doigts sans concurrence avec un
|
||||||
|
/// scroll vertical ou une transformation parente.
|
||||||
|
///
|
||||||
|
/// Interactions :
|
||||||
|
/// - Tap sur zone vide -> ajoute un impact
|
||||||
|
/// - Tap sur un impact -> ouvre l'édition (score / suppression)
|
||||||
|
/// - Appui long + glisser -> déplace l'impact
|
||||||
|
///
|
||||||
|
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
|
||||||
|
/// AnalysisProvider (passé en ChangeNotifierProvider.value côté appelant).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../data/models/shot.dart';
|
||||||
|
import 'analysis_provider.dart';
|
||||||
|
import 'widgets/target_overlay.dart';
|
||||||
|
import 'widgets/shot_details_sheet.dart';
|
||||||
|
|
||||||
|
class ImpactEditorScreen extends StatefulWidget {
|
||||||
|
const ImpactEditorScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImpactEditorScreen> createState() => _ImpactEditorScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||||
|
final TransformationController _transformationController =
|
||||||
|
TransformationController();
|
||||||
|
final GlobalKey _imageKey = GlobalKey();
|
||||||
|
|
||||||
|
double _currentZoomScale = 1.0;
|
||||||
|
String? _movingShotId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_transformationController.addListener(_onTransformChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_transformationController.removeListener(_onTransformChanged);
|
||||||
|
_transformationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTransformChanged() {
|
||||||
|
final scale = _transformationController.value.getMaxScaleOnAxis();
|
||||||
|
if (scale != _currentZoomScale) {
|
||||||
|
setState(() => _currentZoomScale = scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit une position globale en coordonnées relatives (0..1) sur l'image.
|
||||||
|
Offset? _toImageRelative(Offset globalPosition) {
|
||||||
|
final RenderBox? box =
|
||||||
|
_imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null) return null;
|
||||||
|
final local = box.globalToLocal(globalPosition);
|
||||||
|
final relX = (local.dx / box.size.width).clamp(0.0, 1.0);
|
||||||
|
final relY = (local.dy / box.size.height).clamp(0.0, 1.0);
|
||||||
|
return Offset(relX, relY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
|
||||||
|
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
|
||||||
|
{double tolerance = 0.04}) {
|
||||||
|
Shot? closest;
|
||||||
|
double minDistance = double.infinity;
|
||||||
|
for (final shot in provider.shots) {
|
||||||
|
final dx = shot.x - rel.dx;
|
||||||
|
final dy = shot.y - rel.dy;
|
||||||
|
final distance = math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance < minDistance && distance < tolerance) {
|
||||||
|
minDistance = distance;
|
||||||
|
closest = shot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<AnalysisProvider>();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
title: Text('Placement des impacts (${provider.shotCount})'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
tooltip: 'Retour à la calibration',
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text(
|
||||||
|
'VALIDER',
|
||||||
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Bandeau d'aide compact
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.white10,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: const Text(
|
||||||
|
'Tap : ajouter • Tap sur impact : éditer • Appui long : déplacer • Pincer : zoomer',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Zone image plein écran : InteractiveViewer dans un body nu.
|
||||||
|
Expanded(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
transformationController: _transformationController,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 12.0,
|
||||||
|
boundaryMargin: const EdgeInsets.all(80),
|
||||||
|
panEnabled: _movingShotId == null,
|
||||||
|
child: Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
// TAP : éditer si on touche un impact, sinon ajouter.
|
||||||
|
onTapUp: (details) {
|
||||||
|
if (_movingShotId != null) return;
|
||||||
|
final rel = _toImageRelative(details.globalPosition);
|
||||||
|
if (rel == null) return;
|
||||||
|
|
||||||
|
final hit = _hitTestShot(provider, rel);
|
||||||
|
if (hit != null) {
|
||||||
|
showShotDetailsSheet(context, provider, hit);
|
||||||
|
} else {
|
||||||
|
provider.addShot(rel.dx, rel.dy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
|
||||||
|
onLongPressStart: (details) {
|
||||||
|
final rel = _toImageRelative(details.globalPosition);
|
||||||
|
if (rel == null) return;
|
||||||
|
final hit = _hitTestShot(provider, rel, tolerance: 0.06);
|
||||||
|
if (hit != null) {
|
||||||
|
setState(() => _movingShotId = hit.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPressMoveUpdate: (details) {
|
||||||
|
if (_movingShotId == null) return;
|
||||||
|
// Décalage pour que l'impact reste visible au-dessus du doigt.
|
||||||
|
final adjusted =
|
||||||
|
details.globalPosition + const Offset(-25, -35);
|
||||||
|
final rel = _toImageRelative(adjusted);
|
||||||
|
if (rel == null) return;
|
||||||
|
provider.updateShotPosition(
|
||||||
|
_movingShotId!, rel.dx, rel.dy);
|
||||||
|
},
|
||||||
|
onLongPressEnd: (_) {
|
||||||
|
if (_movingShotId != null) {
|
||||||
|
setState(() => _movingShotId = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Image.file(
|
||||||
|
File(provider.imagePath!),
|
||||||
|
key: _imageKey,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: TargetOverlay(
|
||||||
|
targetCenterX: provider.targetCenterX,
|
||||||
|
targetCenterY: provider.targetCenterY,
|
||||||
|
targetRadius: provider.targetRadius,
|
||||||
|
targetType: provider.targetType!,
|
||||||
|
shots: provider.shots,
|
||||||
|
showRings: true,
|
||||||
|
zoomScale: _currentZoomScale,
|
||||||
|
// L'ajout et la sélection sont gérés par le
|
||||||
|
// GestureDetector parent ci-dessus.
|
||||||
|
onShotTapped: (shot) =>
|
||||||
|
showShotDetailsSheet(context, provider, shot),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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 '../../../core/widgets/metric_info_button.dart';
|
||||||
import '../../../services/grouping_analyzer_service.dart';
|
import '../../../services/grouping_analyzer_service.dart';
|
||||||
|
|
||||||
class GroupingStats extends StatelessWidget {
|
class GroupingStats extends StatelessWidget {
|
||||||
@@ -43,31 +44,64 @@ class GroupingStats extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const MetricInfoButton(
|
||||||
|
title: 'Groupement',
|
||||||
|
explanations: [
|
||||||
|
MetricExplanation(
|
||||||
|
'Étalement',
|
||||||
|
'Distance entre vos deux impacts les plus éloignés, '
|
||||||
|
'exprimée en % de la largeur de l\'image. Plus c\'est '
|
||||||
|
'bas, plus le groupement est serré.',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'Dispersion',
|
||||||
|
'Régularité des impacts autour de leur centre commun '
|
||||||
|
'(écart-type). Plus c\'est bas, plus vos tirs sont '
|
||||||
|
'réguliers.',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'Décalage',
|
||||||
|
'Direction du centre de votre groupement par rapport au '
|
||||||
|
'centre de la cible (ex. « Droite » = vos tirs sont '
|
||||||
|
'globalement décalés vers la droite).',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'Étoiles',
|
||||||
|
'Qualité globale du groupement, de ★ (à améliorer) à '
|
||||||
|
'★★★★★ (excellent).',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_buildQualityBadge(context),
|
_buildQualityBadge(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
children: [
|
||||||
_buildStat(
|
Expanded(
|
||||||
context,
|
child: _buildStat(
|
||||||
'Diametre',
|
context,
|
||||||
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
'Étalement',
|
||||||
icon: Icons.straighten,
|
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
||||||
|
icon: Icons.straighten,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildStat(
|
Expanded(
|
||||||
context,
|
child: _buildStat(
|
||||||
'Dispersion',
|
context,
|
||||||
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
'Dispersion',
|
||||||
icon: Icons.scatter_plot,
|
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
||||||
|
icon: Icons.scatter_plot,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildStat(
|
Expanded(
|
||||||
context,
|
child: _buildStat(
|
||||||
'Decalage',
|
context,
|
||||||
offsetDescription,
|
'Décalage',
|
||||||
icon: Icons.compare_arrows,
|
offsetDescription,
|
||||||
|
icon: Icons.compare_arrows,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
|
|||||||
|
|
||||||
String _getOffsetDescription(double offsetX, double offsetY) {
|
String _getOffsetDescription(double offsetX, double offsetY) {
|
||||||
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
||||||
return 'Centre';
|
return 'Centré';
|
||||||
}
|
}
|
||||||
|
|
||||||
String vertical = '';
|
String vertical = '';
|
||||||
String horizontal = '';
|
String horizontal = '';
|
||||||
|
|
||||||
if (offsetY < -0.02) {
|
if (offsetY < -0.02) {
|
||||||
vertical = 'H';
|
vertical = 'Haut';
|
||||||
} else if (offsetY > 0.02) {
|
} else if (offsetY > 0.02) {
|
||||||
vertical = 'B';
|
vertical = 'Bas';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offsetX < -0.02) {
|
if (offsetX < -0.02) {
|
||||||
horizontal = 'G';
|
horizontal = 'Gauche';
|
||||||
} else if (offsetX > 0.02) {
|
} else if (offsetX > 0.02) {
|
||||||
horizontal = 'D';
|
horizontal = 'Droite';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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 '../../../core/widgets/metric_info_button.dart';
|
||||||
import '../../../data/models/target_type.dart';
|
import '../../../data/models/target_type.dart';
|
||||||
import '../../../services/score_calculator_service.dart';
|
import '../../../services/score_calculator_service.dart';
|
||||||
|
|
||||||
@@ -44,6 +45,31 @@ class ScoreCard extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
MetricInfoButton(
|
||||||
|
title: 'Score',
|
||||||
|
explanations: [
|
||||||
|
MetricExplanation(
|
||||||
|
'Total',
|
||||||
|
'Somme des points de tous vos impacts, sur le maximum '
|
||||||
|
'possible (nombre d\'impacts × $maxScore points).',
|
||||||
|
),
|
||||||
|
const MetricExplanation(
|
||||||
|
'Impacts',
|
||||||
|
'Nombre de tirs détectés sur la cible.',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'Moyenne',
|
||||||
|
'Points marqués en moyenne par impact, sur $maxScore.',
|
||||||
|
),
|
||||||
|
const MetricExplanation(
|
||||||
|
'Réussite',
|
||||||
|
'Votre score exprimé en pourcentage du score maximum '
|
||||||
|
'possible. C\'est une mesure du résultat, pas de la '
|
||||||
|
'régularité des tirs.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
@@ -67,11 +93,12 @@ class ScoreCard extends StatelessWidget {
|
|||||||
shotCount > 0
|
shotCount > 0
|
||||||
? (totalScore / shotCount).toStringAsFixed(1)
|
? (totalScore / shotCount).toStringAsFixed(1)
|
||||||
: '-',
|
: '-',
|
||||||
|
subtitle: '/ $maxScore',
|
||||||
),
|
),
|
||||||
if (scoreResult != null)
|
if (scoreResult != null)
|
||||||
_buildScoreStat(
|
_buildScoreStat(
|
||||||
context,
|
context,
|
||||||
'Pourcentage',
|
'Réussite',
|
||||||
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
85
lib/features/analysis/widgets/shot_details_sheet.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/// Bottom sheet de détails d'un impact : modification du score et suppression.
|
||||||
|
///
|
||||||
|
/// Partagée entre l'écran d'analyse (consultation du plotting) et l'éditeur
|
||||||
|
/// d'impacts plein écran, qui opèrent sur le même AnalysisProvider.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../data/models/shot.dart';
|
||||||
|
import '../analysis_provider.dart';
|
||||||
|
|
||||||
|
void showShotDetailsSheet(
|
||||||
|
BuildContext context,
|
||||||
|
AnalysisProvider provider,
|
||||||
|
Shot shot,
|
||||||
|
) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Impact #${provider.shots.indexOf(shot) + 1}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'ID: ${shot.id}',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodySmall
|
||||||
|
?.copyWith(color: Colors.grey, fontSize: 10),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.score),
|
||||||
|
title: const Text('Modifier le score'),
|
||||||
|
trailing: DropdownButton<int>(
|
||||||
|
value: shot.score.clamp(0, 10),
|
||||||
|
items: List.generate(11, (index) => index)
|
||||||
|
.map(
|
||||||
|
(s) => DropdownMenuItem(
|
||||||
|
value: s,
|
||||||
|
child: Text(
|
||||||
|
'$s',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (newScore) {
|
||||||
|
if (newScore != null) {
|
||||||
|
provider.updateShotScore(shot.id, newScore);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
provider.removeShot(shot.id);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
label: const Text(
|
||||||
|
'SUPPRIMER',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
/// Les anneaux sont répartis proportionnellement.
|
/// Les anneaux sont répartis proportionnellement.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:math' as math;
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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';
|
||||||
@@ -83,6 +83,16 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
_initRingRadii();
|
_initRingRadii();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fige et propage la calibration courante vers le provider.
|
||||||
|
///
|
||||||
|
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
|
||||||
|
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
|
||||||
|
/// correspond exactement au dernier réglage validé, sans dépendre d'un
|
||||||
|
/// éventuel rebuild intermédiaire.
|
||||||
|
void commitCalibration() {
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
void _initRingRadii({bool forceRecalculate = false}) {
|
void _initRingRadii({bool forceRecalculate = false}) {
|
||||||
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
|
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
|
||||||
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
|
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
|
||||||
@@ -121,7 +131,14 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||||
shouldReinit = true;
|
shouldReinit = true;
|
||||||
}
|
}
|
||||||
if (widget.initialRingRadii != oldWidget.initialRingRadii && widget.initialRingRadii != null) {
|
// On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
|
||||||
|
// réellement d'une nouvelle détection (ils diffèrent de notre état courant).
|
||||||
|
// Sinon il s'agit de l'écho de notre propre _notifyChange (aller-retour via
|
||||||
|
// le provider) : le clobber effacerait le profil d'origine et casserait le
|
||||||
|
// bouton de réinitialisation de l'espacement.
|
||||||
|
if (widget.initialRingRadii != oldWidget.initialRingRadii &&
|
||||||
|
widget.initialRingRadii != null &&
|
||||||
|
!listEquals(widget.initialRingRadii, _ringRadii)) {
|
||||||
_originalRingRadii = List.from(widget.initialRingRadii!);
|
_originalRingRadii = List.from(widget.initialRingRadii!);
|
||||||
shouldReinit = true;
|
shouldReinit = true;
|
||||||
}
|
}
|
||||||
@@ -328,24 +345,41 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white10),
|
border: Border.all(color: Colors.white10),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
_buildSignLabel('−'),
|
||||||
Row(
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
|
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
||||||
const SizedBox(width: 40),
|
Row(
|
||||||
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
|
||||||
|
const SizedBox(width: 40),
|
||||||
|
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
|
const SizedBox(width: 12),
|
||||||
|
_buildSignLabel('+'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Symbole purement décoratif affiché de part et d'autre de la croix.
|
||||||
|
Widget _buildSignLabel(String text) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/// Overlay visuel de la cible.
|
/// Overlay visuel de la cible.
|
||||||
///
|
///
|
||||||
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement
|
/// Dessine les anneaux de la cible, les impacts et le cercle de groupement.
|
||||||
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout
|
/// Gère uniquement la SÉLECTION d'impacts existants (tap sur un impact).
|
||||||
/// d'impacts et la sélection d'impacts existants.
|
/// L'AJOUT d'un impact est délégué à l'écran parent pour éviter tout conflit
|
||||||
|
/// de gestes avec le zoom/pan de l'InteractiveViewer.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -19,11 +20,9 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
final int ringCount;
|
final int ringCount;
|
||||||
final List<double>? ringRadii;
|
final List<double>? ringRadii;
|
||||||
final void Function(Shot shot)? onShotTapped;
|
final void Function(Shot shot)? onShotTapped;
|
||||||
final void Function(double x, double y)? onAddShot;
|
|
||||||
final double? groupingCenterX;
|
final double? groupingCenterX;
|
||||||
final double? groupingCenterY;
|
final double? groupingCenterY;
|
||||||
final double? groupingDiameter;
|
final double? groupingDiameter;
|
||||||
final List<Shot>? referenceImpacts;
|
|
||||||
final double zoomScale;
|
final double zoomScale;
|
||||||
final bool showRings;
|
final bool showRings;
|
||||||
|
|
||||||
@@ -37,87 +36,77 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
this.ringCount = 10,
|
this.ringCount = 10,
|
||||||
this.ringRadii,
|
this.ringRadii,
|
||||||
this.onShotTapped,
|
this.onShotTapped,
|
||||||
this.onAddShot,
|
|
||||||
this.groupingCenterX,
|
this.groupingCenterX,
|
||||||
this.groupingCenterY,
|
this.groupingCenterY,
|
||||||
this.groupingDiameter,
|
this.groupingDiameter,
|
||||||
this.referenceImpacts,
|
|
||||||
this.zoomScale = 1.0,
|
this.zoomScale = 1.0,
|
||||||
this.showRings = false,
|
this.showRings = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
// IMPORTANT : plus de GestureDetector global ici.
|
||||||
builder: (context, constraints) {
|
// L'ancien GestureDetector (onTapUp couvrant toute la surface, en
|
||||||
return GestureDetector(
|
// HitTestBehavior.translucent) volait les pointeurs au pinch de
|
||||||
behavior: HitTestBehavior.translucent,
|
// l'InteractiveViewer parent et rendait le zoom capricieux.
|
||||||
onTapUp: (details) {
|
//
|
||||||
if (onAddShot != null) {
|
// Désormais :
|
||||||
// Utiliser les constraints pour un calcul précis
|
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
|
||||||
final relX = details.localPosition.dx / constraints.maxWidth;
|
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
|
||||||
final relY = details.localPosition.dy / constraints.maxHeight;
|
// zones de tap localisées (deferToChild) placées sur chaque impact.
|
||||||
onAddShot!(relX, relY);
|
return IgnorePointer(
|
||||||
}
|
ignoring: false,
|
||||||
},
|
child: CustomPaint(
|
||||||
child: CustomPaint(
|
painter: _TargetOverlayPainter(
|
||||||
painter: _TargetOverlayPainter(
|
shots: shots,
|
||||||
shots: shots,
|
targetCenterX: targetCenterX,
|
||||||
targetCenterX: targetCenterX,
|
targetCenterY: targetCenterY,
|
||||||
targetCenterY: targetCenterY,
|
targetRadius: targetRadius,
|
||||||
targetRadius: targetRadius,
|
targetType: targetType,
|
||||||
targetType: targetType,
|
ringCount: ringCount,
|
||||||
ringCount: ringCount,
|
ringRadii: ringRadii,
|
||||||
ringRadii: ringRadii,
|
groupingCenterX: groupingCenterX,
|
||||||
groupingCenterX: groupingCenterX,
|
groupingCenterY: groupingCenterY,
|
||||||
groupingCenterY: groupingCenterY,
|
groupingDiameter: groupingDiameter,
|
||||||
groupingDiameter: groupingDiameter,
|
zoomScale: zoomScale,
|
||||||
referenceImpacts: referenceImpacts,
|
showRings: showRings,
|
||||||
zoomScale: zoomScale,
|
),
|
||||||
showRings: showRings,
|
child: LayoutBuilder(
|
||||||
),
|
builder: (context, constraints) {
|
||||||
child: Stack(
|
return Stack(
|
||||||
children: shots.map((shot) {
|
children: shots.map((shot) {
|
||||||
|
final x = shot.x * constraints.maxWidth;
|
||||||
|
final y = shot.y * constraints.maxHeight;
|
||||||
|
// Zone de tap qui reste constante à l'écran malgré le zoom.
|
||||||
|
final tapSize = 30 / zoomScale;
|
||||||
|
final halfTapSize = tapSize / 2;
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 0,
|
left: x - halfTapSize,
|
||||||
top: 0,
|
top: y - halfTapSize,
|
||||||
right: 0,
|
child: GestureDetector(
|
||||||
bottom: 0,
|
// deferToChild : ne capte le toucher QUE sur la zone du
|
||||||
child: LayoutBuilder(
|
// Container (un cercle opaque au hit-test), pas ailleurs.
|
||||||
builder: (context, innerConstraints) {
|
// Le reste de la surface reste donc disponible pour le
|
||||||
final x = shot.x * innerConstraints.maxWidth;
|
// pinch/pan de l'InteractiveViewer.
|
||||||
final y = shot.y * innerConstraints.maxHeight;
|
behavior: HitTestBehavior.deferToChild,
|
||||||
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran)
|
onTap: () => onShotTapped?.call(shot),
|
||||||
final tapSize = 30 / zoomScale;
|
child: Container(
|
||||||
final halfTapSize = tapSize / 2;
|
width: tapSize,
|
||||||
return Stack(
|
height: tapSize,
|
||||||
children: [
|
decoration: const BoxDecoration(
|
||||||
Positioned(
|
// Opaque pour le hit-test (couleur transparente visuellement
|
||||||
left: x - halfTapSize,
|
// mais non nulle), pour que le tap soit bien capté ici.
|
||||||
top: y - halfTapSize,
|
color: Color(0x01000000),
|
||||||
child: GestureDetector(
|
shape: BoxShape.circle,
|
||||||
behavior: HitTestBehavior.translucent,
|
),
|
||||||
onTap: () => onShotTapped?.call(shot),
|
),
|
||||||
child: Container(
|
|
||||||
width: tapSize,
|
|
||||||
height: tapSize,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +122,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
final double? groupingCenterX;
|
final double? groupingCenterX;
|
||||||
final double? groupingCenterY;
|
final double? groupingCenterY;
|
||||||
final double? groupingDiameter;
|
final double? groupingDiameter;
|
||||||
final List<Shot>? referenceImpacts;
|
|
||||||
final double zoomScale;
|
final double zoomScale;
|
||||||
final bool showRings;
|
final bool showRings;
|
||||||
|
|
||||||
@@ -148,7 +136,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
this.groupingCenterX,
|
this.groupingCenterX,
|
||||||
this.groupingCenterY,
|
this.groupingCenterY,
|
||||||
this.groupingDiameter,
|
this.groupingDiameter,
|
||||||
this.referenceImpacts,
|
|
||||||
this.zoomScale = 1.0,
|
this.zoomScale = 1.0,
|
||||||
this.showRings = false,
|
this.showRings = false,
|
||||||
});
|
});
|
||||||
@@ -169,13 +156,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
_drawImpact(canvas, size, shot);
|
_drawImpact(canvas, size, shot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw reference impacts (with different color)
|
|
||||||
if (referenceImpacts != null) {
|
|
||||||
for (final ref in referenceImpacts!) {
|
|
||||||
_drawReferenceImpact(canvas, size, ref);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawTargetCenter(Canvas canvas, Size size) {
|
void _drawTargetCenter(Canvas canvas, Size size) {
|
||||||
@@ -211,8 +191,8 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
final prevMultiplier = i == 0
|
final prevMultiplier = i == 0
|
||||||
? 0.0
|
? 0.0
|
||||||
: (ringRadii != null && ringRadii!.length == ringCount)
|
: (ringRadii != null && ringRadii!.length == ringCount)
|
||||||
? ringRadii![i - 1]
|
? ringRadii![i - 1]
|
||||||
: i / ringCount;
|
: i / ringCount;
|
||||||
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
|
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
|
||||||
final score = 10 - i;
|
final score = 10 - i;
|
||||||
|
|
||||||
@@ -292,16 +272,17 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
final strokeWidth = 3 / zoomScale;
|
final strokeWidth = 3 / zoomScale;
|
||||||
final fontSize = 10 / zoomScale;
|
final fontSize = 10 / zoomScale;
|
||||||
|
|
||||||
// Draw outer circle (white outline for visibility)
|
// Draw outer circle (white outline for visibility) — gardé OPAQUE pour
|
||||||
|
// bien repérer le centre même quand le remplissage est transparent.
|
||||||
final outlinePaint = Paint()
|
final outlinePaint = Paint()
|
||||||
..color = AppTheme.impactOutlineColor
|
..color = AppTheme.impactOutlineColor
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = strokeWidth;
|
..strokeWidth = strokeWidth;
|
||||||
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
||||||
|
|
||||||
// Draw impact marker
|
// Draw impact marker — TRANSPARENCE 30% pour voir l'impact réel derrière
|
||||||
final impactPaint = Paint()
|
final impactPaint = Paint()
|
||||||
..color = AppTheme.impactColor
|
..color = AppTheme.impactColor.withValues(alpha: 0.3)
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
|
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
|
||||||
|
|
||||||
@@ -324,48 +305,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) {
|
|
||||||
final x = ref.x * size.width;
|
|
||||||
final y = ref.y * size.height;
|
|
||||||
|
|
||||||
// Tailles fixes divisées par le zoom pour rester constantes à l'écran
|
|
||||||
final outerRadius = 12 / zoomScale;
|
|
||||||
final innerRadius = 10 / zoomScale;
|
|
||||||
final strokeWidth = 3 / zoomScale;
|
|
||||||
final fontSize = 12 / zoomScale;
|
|
||||||
|
|
||||||
// Draw outer circle (white outline for visibility)
|
|
||||||
final outlinePaint = Paint()
|
|
||||||
..color = Colors.white
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = strokeWidth;
|
|
||||||
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
|
||||||
|
|
||||||
// Draw reference marker (purple)
|
|
||||||
final refPaint = Paint()
|
|
||||||
..color = Colors.deepPurple
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
canvas.drawCircle(Offset(x, y), innerRadius, refPaint);
|
|
||||||
|
|
||||||
// Draw "R" to indicate reference
|
|
||||||
final textPainter = TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: 'R',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: fontSize,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(
|
|
||||||
canvas,
|
|
||||||
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
|
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
|
||||||
return shots != oldDelegate.shots ||
|
return shots != oldDelegate.shots ||
|
||||||
@@ -377,7 +316,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
groupingCenterX != oldDelegate.groupingCenterX ||
|
groupingCenterX != oldDelegate.groupingCenterX ||
|
||||||
groupingCenterY != oldDelegate.groupingCenterY ||
|
groupingCenterY != oldDelegate.groupingCenterY ||
|
||||||
groupingDiameter != oldDelegate.groupingDiameter ||
|
groupingDiameter != oldDelegate.groupingDiameter ||
|
||||||
referenceImpacts != oldDelegate.referenceImpacts ||
|
|
||||||
zoomScale != oldDelegate.zoomScale ||
|
zoomScale != oldDelegate.zoomScale ||
|
||||||
showRings != oldDelegate.showRings;
|
showRings != oldDelegate.showRings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,28 +9,17 @@ import '../analysis/analysis_screen.dart';
|
|||||||
import 'widgets/crop_overlay.dart';
|
import 'widgets/crop_overlay.dart';
|
||||||
|
|
||||||
class CropScreen extends StatefulWidget {
|
class CropScreen extends StatefulWidget {
|
||||||
/// Image affichée/recadrée dans cet écran. Au tout premier passage, c'est
|
|
||||||
/// aussi l'image originale. Lors des allers-retours, on recharge toujours
|
|
||||||
/// l'ORIGINAL ici (jamais une image déjà recadrée) pour éviter le zoom
|
|
||||||
/// cumulatif.
|
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
|
|
||||||
/// Chemin de l'image ORIGINALE (jamais recadrée). Si null, [imagePath] est
|
|
||||||
/// considéré comme l'original (premier passage depuis la caméra/galerie).
|
|
||||||
final String? originalImagePath;
|
|
||||||
|
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
|
final double? initialScale;
|
||||||
/// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement
|
final Offset? initialOffset;
|
||||||
/// PAS restauré : on repart toujours de l'image entière.
|
|
||||||
final double? initialRotation;
|
|
||||||
|
|
||||||
const CropScreen({
|
const CropScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imagePath,
|
required this.imagePath,
|
||||||
this.originalImagePath,
|
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
this.initialRotation,
|
this.initialScale,
|
||||||
|
this.initialOffset,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,20 +45,14 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
late Size _viewportSize;
|
late Size _viewportSize;
|
||||||
late double _cropSize;
|
late double _cropSize;
|
||||||
|
|
||||||
/// L'image effectivement travaillée par cet écran : toujours l'originale.
|
|
||||||
String get _workingImagePath =>
|
|
||||||
widget.originalImagePath ?? widget.imagePath;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// On restaure uniquement la rotation (pas le zoom).
|
|
||||||
_rotation = widget.initialRotation ?? 0.0;
|
|
||||||
_loadImageInfo();
|
_loadImageInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadImageInfo() async {
|
Future<void> _loadImageInfo() async {
|
||||||
final file = File(_workingImagePath);
|
final file = File(widget.imagePath);
|
||||||
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -118,24 +101,9 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
// Zone interactive de crop
|
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.white10),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// TEXTE D'AIDE AJUSTÉ
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@@ -143,21 +111,53 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Flexible(
|
||||||
'Alignez et pivotez la cible sur la croix',
|
child: Text(
|
||||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
'Alignez et pivotez la cible sur la croix',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
||||||
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Zone interactive de crop, CARRÉE. La photo la remplit entièrement
|
||||||
|
// (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre
|
||||||
|
// est récupérable en déplaçant/zoomant. La sortie d'analyse reste
|
||||||
|
// carrée, donc la cible n'est pas déformée.
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel.
|
||||||
|
_buildDirectionalPad(),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
||||||
@@ -255,24 +255,11 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
|
||||||
// FIX : On calcule d'abord la taille de la photo affichée avant de définir la taille du cadre vert !
|
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
|
||||||
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0;
|
// visée = toute la zone visible → aucun bord noir autour du cadre.
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
|
||||||
|
|
||||||
double displayWidth, displayHeight;
|
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
|
||||||
if (imageAspect > viewportAspect) {
|
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
|
||||||
displayHeight = _viewportSize.height;
|
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum),
|
|
||||||
// au lieu des 85% précédents qui rognaient inutilement les bords.
|
|
||||||
_cropSize = math.min(displayWidth, displayHeight);
|
|
||||||
|
|
||||||
if (_scale == 1.0 && _offset == Offset.zero) {
|
|
||||||
_initializeImagePosition();
|
_initializeImagePosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,12 +274,12 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
child: Transform(
|
child: Transform(
|
||||||
transform: Matrix4.identity()
|
transform: Matrix4.identity()
|
||||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
||||||
..scale(_scale, _scale)
|
..scaleByDouble(_scale, _scale, _scale, 1.0)
|
||||||
..rotateZ(_rotation * (math.pi / 180)),
|
..rotateZ(_rotation * (math.pi / 180)),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Image.file(
|
child: Image.file(
|
||||||
File(_workingImagePath),
|
File(widget.imagePath),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.cover,
|
||||||
width: _viewportSize.width,
|
width: _viewportSize.width,
|
||||||
height: _viewportSize.height,
|
height: _viewportSize.height,
|
||||||
),
|
),
|
||||||
@@ -309,14 +296,14 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
color: const Color(0xFF00FF00).withOpacity(0.6),
|
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
color: const Color(0xFF00FF00).withOpacity(0.6),
|
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -363,23 +350,71 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
void _initializeImagePosition() {
|
void _initializeImagePosition() {
|
||||||
if (_imageSize == null) return;
|
if (_imageSize == null) return;
|
||||||
|
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
_scale = widget.initialScale ?? 1.0;
|
||||||
|
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
|
||||||
|
|
||||||
// 1. Calcul strict de la taille de l'image affichée en BoxFit.contain
|
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
|
||||||
double displayWidth, displayHeight;
|
if (widget.initialOffset != null) {
|
||||||
if (imageAspect > viewportAspect) {
|
_offset = widget.initialOffset!;
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
} else {
|
||||||
displayHeight = _viewportSize.height;
|
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// On repart TOUJOURS de l'image entière, centrée, sans zoom :
|
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
|
||||||
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours.
|
// Les symboles « − » et « + » de part et d'autre sont purement décoratifs.
|
||||||
_scale = 1.0;
|
Widget _buildDirectionalPad() {
|
||||||
_offset = Offset.zero;
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildCropSignLabel('−'),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildCropDirButton(Icons.keyboard_arrow_up, () => _nudge(0, -1)),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildCropDirButton(Icons.keyboard_arrow_left, () => _nudge(-1, 0)),
|
||||||
|
const SizedBox(width: 28),
|
||||||
|
_buildCropDirButton(Icons.keyboard_arrow_right, () => _nudge(1, 0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildCropDirButton(Icons.keyboard_arrow_down, () => _nudge(0, 1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_buildCropSignLabel('+'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(6)),
|
||||||
|
margin: const EdgeInsets.all(2),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(icon, color: Colors.white, size: 22),
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCropSignLabel(String text) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _nudge(double dx, double dy) {
|
||||||
|
setState(() {
|
||||||
|
_offset = _offset + Offset(dx, dy);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScaleStart(ScaleStartDetails details) {
|
void _onScaleStart(ScaleStartDetails details) {
|
||||||
@@ -399,24 +434,26 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
Future<void> _onCropConfirm() async {
|
Future<void> _onCropConfirm() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer
|
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
|
||||||
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants
|
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
|
||||||
final savedScale = _scale;
|
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
|
||||||
final savedOffset = _offset;
|
final double displayPerSourcePx = math.max(
|
||||||
_scale = 1.0;
|
_viewportSize.width / _imageSize!.width,
|
||||||
_offset = Offset.zero;
|
_viewportSize.height / _imageSize!.height,
|
||||||
|
);
|
||||||
|
|
||||||
final cropRect = _calculateCropRect();
|
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
|
||||||
|
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
|
||||||
// On restaure pour l'affichage (au cas où on revient en arrière)
|
// sont remplis en noir → la position choisie est respectée à l'identique.
|
||||||
_scale = savedScale;
|
final croppedImagePath = await _cropService.cropViewport(
|
||||||
_offset = savedOffset;
|
sourcePath: widget.imagePath,
|
||||||
|
offsetDx: _offset.dx,
|
||||||
// Le crop est TOUJOURS calculé sur l'image originale, jamais sur un
|
offsetDy: _offset.dy,
|
||||||
// résultat déjà recadré.
|
displayPerSourcePx: displayPerSourcePx,
|
||||||
final croppedImagePath = await _cropService.cropToSquare(
|
cropSizeDisplay: _cropSize,
|
||||||
_workingImagePath,
|
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
|
||||||
cropRect,
|
// il n'agrandit pas le rendu de sortie.
|
||||||
|
zoomScale: _scale,
|
||||||
rotationDegrees: _rotation,
|
rotationDegrees: _rotation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -430,13 +467,16 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => AnalysisScreen(
|
builder: (_) => AnalysisScreen(
|
||||||
imagePath: croppedImagePath,
|
imagePath: croppedImagePath,
|
||||||
// On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir
|
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
|
||||||
// revenir au centrage sans jamais re-recadrer le résultat.
|
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
|
||||||
originalImagePath: _workingImagePath,
|
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
|
||||||
cropRotation: _rotation,
|
originalImagePath: widget.imagePath,
|
||||||
targetType: widget.targetType,
|
targetType: widget.targetType,
|
||||||
initialCenterX: targetCenterX,
|
initialCenterX: targetCenterX,
|
||||||
initialCenterY: targetCenterY,
|
initialCenterY: targetCenterY,
|
||||||
|
cropScale: 1.0,
|
||||||
|
cropOffset: Offset.zero,
|
||||||
|
cropRotation: 0.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -450,43 +490,4 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CropRect _calculateCropRect() {
|
|
||||||
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
|
||||||
|
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
|
||||||
|
|
||||||
double displayWidth, displayHeight;
|
|
||||||
if (imageAspect > viewportAspect) {
|
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
|
||||||
displayHeight = _viewportSize.height;
|
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
|
||||||
}
|
|
||||||
|
|
||||||
final scaledWidth = displayWidth * _scale;
|
|
||||||
final scaledHeight = displayHeight * _scale;
|
|
||||||
|
|
||||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
|
||||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
|
||||||
|
|
||||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
|
||||||
final imageTop = imageCenterY - scaledHeight / 2;
|
|
||||||
|
|
||||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
|
||||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
|
||||||
|
|
||||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
|
||||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
|
||||||
final relCropWidth = _cropSize / scaledWidth;
|
|
||||||
final relCropHeight = _cropSize / scaledHeight;
|
|
||||||
|
|
||||||
return CropRect(
|
|
||||||
x: relCropLeft.clamp(0.0, 1.0),
|
|
||||||
y: relCropTop.clamp(0.0, 1.0),
|
|
||||||
width: relCropWidth.clamp(0.0, 1.0),
|
|
||||||
height: relCropHeight.clamp(0.0, 1.0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
late Weapon _weapon;
|
late Weapon _weapon;
|
||||||
List<MaintenanceEntry> _maintenance = [];
|
List<MaintenanceEntry> _maintenance = [];
|
||||||
int _totalRounds = 0;
|
int _totalRounds = 0;
|
||||||
|
int _sessionCount = 0;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,10 +33,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final history = await repository.getMaintenanceHistory(_weapon.id);
|
final history = await repository.getMaintenanceHistory(_weapon.id);
|
||||||
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
||||||
|
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_maintenance = history;
|
_maintenance = history;
|
||||||
_totalRounds = rounds;
|
_totalRounds = rounds;
|
||||||
|
_sessionCount = sessions;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -49,7 +52,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
onPressed: () => _showEditWeaponDialog(context),
|
onPressed: _showEditWeaponDialog,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -71,7 +74,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () => _showAddMaintenanceDialog(context),
|
onPressed: _showAddMaintenanceDialog,
|
||||||
label: const Text('Entretien'),
|
label: const Text('Entretien'),
|
||||||
icon: const Icon(Icons.build),
|
icon: const Icon(Icons.build),
|
||||||
),
|
),
|
||||||
@@ -93,7 +96,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _buildStatCard(
|
child: _buildStatCard(
|
||||||
'Sessions',
|
'Sessions',
|
||||||
'N/A',
|
_sessionCount.toString(),
|
||||||
Icons.history,
|
Icons.history,
|
||||||
Colors.orange,
|
Colors.orange,
|
||||||
),
|
),
|
||||||
@@ -149,7 +152,24 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
|
||||||
|
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
minimumSize: const Size(36, 36),
|
||||||
|
),
|
||||||
|
onPressed: _showEditAccessoriesDialog,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
||||||
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
|
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
|
||||||
@@ -203,7 +223,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
title: Text(entry.type.displayName),
|
title: Text(entry.type.displayName),
|
||||||
subtitle: Text(entry.description),
|
subtitle: Text(entry.description),
|
||||||
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
|
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
|
||||||
onLongPress: () => _confirmDeleteMaintenance(context, entry),
|
onLongPress: () => _confirmDeleteMaintenance(entry),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -232,7 +252,36 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showEditWeaponDialog(BuildContext context) async {
|
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
|
||||||
|
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
|
||||||
|
// (ex: Modérateur) se retrouve caché sous le clavier.
|
||||||
|
Widget _autoScrollOnFocus(Widget child) {
|
||||||
|
return Builder(
|
||||||
|
builder: (context) => Focus(
|
||||||
|
canRequestFocus: false,
|
||||||
|
skipTraversal: true,
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus) return;
|
||||||
|
// On attend que le clavier ait fini de redimensionner la vue.
|
||||||
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
|
if (context.mounted) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
context,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditWeaponDialog() async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
final nameController = TextEditingController(text: _weapon.name);
|
final nameController = TextEditingController(text: _weapon.name);
|
||||||
final caliberController = TextEditingController(text: _weapon.caliber);
|
final caliberController = TextEditingController(text: _weapon.caliber);
|
||||||
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
|
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
|
||||||
@@ -253,41 +302,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
||||||
),
|
)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: customNameController,
|
controller: customNameController,
|
||||||
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
||||||
),
|
)),
|
||||||
DropdownButtonFormField<WeaponType>(
|
DropdownButtonFormField<WeaponType>(
|
||||||
value: selectedType,
|
initialValue: selectedType,
|
||||||
decoration: const InputDecoration(labelText: 'Type'),
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
onChanged: (v) => setState(() => selectedType = v!),
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
),
|
),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: caliberController,
|
controller: caliberController,
|
||||||
decoration: const InputDecoration(labelText: 'Calibre'),
|
decoration: const InputDecoration(labelText: 'Calibre'),
|
||||||
),
|
)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: opticController,
|
controller: opticController,
|
||||||
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
||||||
),
|
)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: silencerController,
|
controller: silencerController,
|
||||||
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
||||||
),
|
)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: triggerController,
|
controller: triggerController,
|
||||||
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
||||||
),
|
)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
Row(
|
_autoScrollOnFocus(Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -305,12 +354,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
)),
|
||||||
TextField(
|
_autoScrollOnFocus(TextField(
|
||||||
controller: notesController,
|
controller: notesController,
|
||||||
decoration: const InputDecoration(labelText: 'Notes'),
|
decoration: const InputDecoration(labelText: 'Notes'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
),
|
)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -336,17 +385,125 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
customName: customNameController.text.isEmpty ? null : customNameController.text,
|
customName: customNameController.text.isEmpty ? null : customNameController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
await repository.updateWeapon(updatedWeapon);
|
await repository.updateWeapon(updatedWeapon);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_weapon = updatedWeapon;
|
_weapon = updatedWeapon;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAddMaintenanceDialog(BuildContext context) async {
|
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
|
||||||
|
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
|
||||||
|
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
|
||||||
|
// pouvoir comparer les scores selon la configuration utilisée.
|
||||||
|
void _showEditAccessoriesDialog() async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final opticController = TextEditingController(text: _weapon.optic);
|
||||||
|
final silencerController = TextEditingController(text: _weapon.silencer);
|
||||||
|
final triggerController = TextEditingController(text: _weapon.trigger);
|
||||||
|
final customNameController = TextEditingController(text: _weapon.customName);
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Modifier les accessoires'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
|
||||||
|
'pour pouvoir comparer les scores selon la configuration.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: opticController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: silencerController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: triggerController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: customNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Surnom de la configuration',
|
||||||
|
hintText: 'ex: Echelon + Holosun',
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != true || !mounted) return;
|
||||||
|
|
||||||
|
final newOptic = opticController.text.isEmpty ? null : opticController.text;
|
||||||
|
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
|
||||||
|
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
|
||||||
|
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
|
||||||
|
|
||||||
|
// Rien à faire si aucun accessoire n'a changé.
|
||||||
|
final unchanged = newOptic == _weapon.optic &&
|
||||||
|
newSilencer == _weapon.silencer &&
|
||||||
|
newTrigger == _weapon.trigger;
|
||||||
|
if (unchanged) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
|
||||||
|
if (newCustomName == null) {
|
||||||
|
final accessories = [newOptic, newSilencer, newTrigger]
|
||||||
|
.where((a) => a != null && a.isNotEmpty)
|
||||||
|
.join(' / ');
|
||||||
|
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
|
||||||
|
}
|
||||||
|
|
||||||
|
final newWeapon = await repository.addWeapon(
|
||||||
|
name: _weapon.name,
|
||||||
|
type: _weapon.type,
|
||||||
|
caliber: _weapon.caliber,
|
||||||
|
magazineCount: _weapon.magazineCount,
|
||||||
|
magazineCapacity: _weapon.magazineCapacity,
|
||||||
|
notes: _weapon.notes,
|
||||||
|
optic: newOptic,
|
||||||
|
silencer: newSilencer,
|
||||||
|
trigger: newTrigger,
|
||||||
|
customName: newCustomName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
|
||||||
|
);
|
||||||
|
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddMaintenanceDialog() async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
final descController = TextEditingController();
|
final descController = TextEditingController();
|
||||||
MaintenanceType selectedType = MaintenanceType.cleaning;
|
MaintenanceType selectedType = MaintenanceType.cleaning;
|
||||||
|
DateTime selectedDate = DateTime.now();
|
||||||
|
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -357,7 +514,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<MaintenanceType>(
|
DropdownButtonFormField<MaintenanceType>(
|
||||||
value: selectedType,
|
initialValue: selectedType,
|
||||||
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
|
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
|
||||||
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
onChanged: (v) => setState(() => selectedType = v!),
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
@@ -367,6 +524,27 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedDate,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => selectedDate = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Date',
|
||||||
|
suffixIcon: Icon(Icons.calendar_today, size: 18),
|
||||||
|
),
|
||||||
|
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -378,18 +556,21 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result == true && descController.text.isNotEmpty) {
|
if (result == true && descController.text.isNotEmpty) {
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
await repository.addMaintenanceEntry(
|
await repository.addMaintenanceEntry(
|
||||||
weaponId: _weapon.id,
|
weaponId: _weapon.id,
|
||||||
type: selectedType,
|
type: selectedType,
|
||||||
description: descController.text,
|
description: descController.text,
|
||||||
roundsSinceLast: _totalRounds,
|
roundsSinceLast: _totalRounds,
|
||||||
|
date: selectedDate,
|
||||||
);
|
);
|
||||||
|
if (!mounted) return;
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async {
|
void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -406,8 +587,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
await repository.deleteMaintenanceEntry(entry.id);
|
await repository.deleteMaintenanceEntry(entry.id);
|
||||||
|
if (!mounted) return;
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
? _buildEmptyState()
|
? _buildEmptyState()
|
||||||
: _buildWeaponList(),
|
: _buildWeaponList(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => _showAddWeaponDialog(context),
|
onPressed: _showAddWeaponDialog,
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -62,7 +62,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
const Text('Aucune arme enregistrée'),
|
const Text('Aucune arme enregistrée'),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => _showAddWeaponDialog(context),
|
onPressed: _showAddWeaponDialog,
|
||||||
child: const Text('Ajouter ma première arme'),
|
child: const Text('Ajouter ma première arme'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -76,26 +76,11 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
itemCount: _weapons.length,
|
itemCount: _weapons.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final weapon = _weapons[index];
|
final weapon = _weapons[index];
|
||||||
|
final accessories = _accessoryChips(weapon);
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: ListTile(
|
child: InkWell(
|
||||||
leading: CircleAvatar(
|
borderRadius: BorderRadius.circular(12),
|
||||||
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
|
|
||||||
child: Icon(
|
|
||||||
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
subtitle: Text('${weapon.type.displayName} • ${weapon.caliber}'),
|
|
||||||
trailing: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
|
|
||||||
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -103,14 +88,100 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
);
|
);
|
||||||
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
||||||
},
|
},
|
||||||
onLongPress: () => _confirmDelete(context, weapon),
|
onLongPress: () => _confirmDelete(weapon),
|
||||||
|
child: Padding(
|
||||||
|
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
|
child: Icon(
|
||||||
|
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
if (accessories.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: accessories,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
] else
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${weapon.type.displayName} • ${weapon.caliber}',
|
||||||
|
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
|
||||||
|
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAddWeaponDialog(BuildContext context) async {
|
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
|
||||||
|
// Seuls les accessoires effectivement définis sont affichés ; la card
|
||||||
|
// s'agrandit donc en fonction du nombre d'accessoires.
|
||||||
|
List<Widget> _accessoryChips(Weapon weapon) {
|
||||||
|
final items = <(IconData, String)>[];
|
||||||
|
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
|
||||||
|
items.add((Icons.center_focus_strong, weapon.optic!));
|
||||||
|
}
|
||||||
|
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
|
||||||
|
items.add((Icons.volume_off, weapon.silencer!));
|
||||||
|
}
|
||||||
|
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
|
||||||
|
items.add((Icons.touch_app, weapon.trigger!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(item.$2, style: const TextStyle(fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddWeaponDialog() async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
final nameController = TextEditingController();
|
final nameController = TextEditingController();
|
||||||
final caliberController = TextEditingController();
|
final caliberController = TextEditingController();
|
||||||
final magCountController = TextEditingController(text: '2');
|
final magCountController = TextEditingController(text: '2');
|
||||||
@@ -131,7 +202,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
|
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField<WeaponType>(
|
DropdownButtonFormField<WeaponType>(
|
||||||
value: selectedType,
|
initialValue: selectedType,
|
||||||
decoration: const InputDecoration(labelText: 'Type'),
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
onChanged: (v) => setState(() => selectedType = v!),
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
@@ -171,7 +242,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result == true && nameController.text.isNotEmpty) {
|
if (result == true && nameController.text.isNotEmpty) {
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
await repository.addWeapon(
|
await repository.addWeapon(
|
||||||
name: nameController.text,
|
name: nameController.text,
|
||||||
type: selectedType,
|
type: selectedType,
|
||||||
@@ -179,11 +249,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
||||||
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
||||||
);
|
);
|
||||||
|
if (!mounted) return;
|
||||||
_loadWeapons();
|
_loadWeapons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmDelete(BuildContext context, Weapon weapon) async {
|
void _confirmDelete(Weapon weapon) async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -200,8 +273,8 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
await repository.deleteWeapon(weapon.id);
|
await repository.deleteWeapon(weapon.id);
|
||||||
|
if (!mounted) return;
|
||||||
_loadWeapons();
|
_loadWeapons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import 'widgets/session_list_item.dart';
|
|||||||
import 'widgets/history_chart.dart';
|
import 'widgets/history_chart.dart';
|
||||||
|
|
||||||
class HistoryScreen extends StatefulWidget {
|
class HistoryScreen extends StatefulWidget {
|
||||||
const HistoryScreen({super.key});
|
/// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
|
||||||
|
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
|
||||||
|
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
|
||||||
|
/// tant qu'on ne tire pas manuellement pour rafraîchir).
|
||||||
|
final int refreshTick;
|
||||||
|
|
||||||
|
const HistoryScreen({super.key, this.refreshTick = 0});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||||
@@ -31,6 +37,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
_loadSessions();
|
_loadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(HistoryScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
|
||||||
|
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
|
||||||
|
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||||
|
_loadSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadSessions() async {
|
Future<void> _loadSessions() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
@@ -43,6 +59,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_sessions = sessions;
|
_sessions = sessions;
|
||||||
|
|
||||||
|
// --- FILTRAGE PAR TYPE DE CIBLE ---
|
||||||
|
// Une session est retenue si au moins une de ses cibles est du type choisi.
|
||||||
|
if (_filterType != null) {
|
||||||
|
_sessions = _sessions
|
||||||
|
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
|
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
|
||||||
if (_selectedDateRange != null) {
|
if (_selectedDateRange != null) {
|
||||||
_sessions = _sessions.where((s) {
|
_sessions = _sessions.where((s) {
|
||||||
@@ -103,7 +127,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
|
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
|
||||||
secondary: AppTheme.primaryColor,
|
secondary: AppTheme.primaryColor,
|
||||||
),
|
),
|
||||||
dialogBackgroundColor: Colors.white,
|
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
|
||||||
textButtonTheme: TextButtonThemeData(
|
textButtonTheme: TextButtonThemeData(
|
||||||
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
|
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
|
||||||
),
|
),
|
||||||
@@ -125,17 +149,29 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Historique'),
|
title: const Text('Historique'),
|
||||||
actions: [
|
actions: [
|
||||||
PopupMenuButton<TargetType?>(
|
// NOTE : on passe par un String sentinelle ('all') car un
|
||||||
icon: const Icon(Icons.filter_list),
|
// PopupMenuItem avec value null ne déclenche jamais onSelected
|
||||||
onSelected: (type) {
|
// (Flutter l'interprète comme une annulation du menu).
|
||||||
setState(() => _filterType = type);
|
PopupMenuButton<String>(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.filter_list,
|
||||||
|
// Icône colorée quand un filtre est actif, pour le rendre visible.
|
||||||
|
color: _filterType != null ? AppTheme.primaryColor : null,
|
||||||
|
),
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
_filterType =
|
||||||
|
value == 'all' ? null : TargetType.fromString(value);
|
||||||
|
});
|
||||||
_loadSessions();
|
_loadSessions();
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(value: null, child: Text('Tous')),
|
const PopupMenuItem(value: 'all', child: Text('Tous')),
|
||||||
...TargetType.values.map(
|
...TargetType.values.map(
|
||||||
(type) =>
|
(type) => PopupMenuItem(
|
||||||
PopupMenuItem(value: type, child: Text(type.displayName)),
|
value: type.name,
|
||||||
|
child: Text(type.displayName),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,12 +25,43 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
List<Session> _recentSessions = [];
|
List<Session> _recentSessions = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
SessionProvider? _sessionProvider;
|
||||||
|
bool _wasSessionActive = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final provider = context.read<SessionProvider>();
|
||||||
|
if (provider != _sessionProvider) {
|
||||||
|
_sessionProvider?.removeListener(_onSessionChanged);
|
||||||
|
_sessionProvider = provider;
|
||||||
|
_sessionProvider!.addListener(_onSessionChanged);
|
||||||
|
_wasSessionActive = provider.isSessionActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSessionChanged() {
|
||||||
|
final isActive = _sessionProvider?.isSessionActive ?? false;
|
||||||
|
// Quand une session vient de se terminer, on rafraîchit les stats
|
||||||
|
// automatiquement pour prendre en compte la dernière session.
|
||||||
|
if (_wasSessionActive && !isActive) {
|
||||||
|
_loadStats();
|
||||||
|
}
|
||||||
|
_wasSessionActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sessionProvider?.removeListener(_onSessionChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadStats() async {
|
Future<void> _loadStats() async {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final stats = await repository.getStatistics();
|
final stats = await repository.getStatistics();
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../../data/models/session.dart';
|
|
||||||
import '../../data/models/target_analysis.dart';
|
import '../../data/models/target_analysis.dart';
|
||||||
import '../../data/models/weapon.dart';
|
|
||||||
|
|
||||||
class SessionProvider extends ChangeNotifier {
|
class SessionProvider extends ChangeNotifier {
|
||||||
DateTime? _sessionDate;
|
DateTime? _sessionDate;
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
|||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<Weapon>(
|
DropdownButtonFormField<Weapon>(
|
||||||
value: _selectedWeapon,
|
initialValue: _selectedWeapon,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Sélectionner une arme',
|
labelText: 'Sélectionner une arme',
|
||||||
prefixIcon: const Icon(Icons.shield),
|
prefixIcon: const Icon(Icons.shield),
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (mounted) setState(() => _isLoadingStats = false);
|
if (mounted) setState(() => _isLoadingStats = false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors du chargement des statistiques: $e');
|
debugPrint('Erreur lors du chargement des statistiques: $e');
|
||||||
if (mounted) setState(() => _isLoadingStats = false);
|
if (mounted) setState(() => _isLoadingStats = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,8 +85,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showThemeDialog() {
|
void _showThemeDialog() {
|
||||||
final themeProvider = context.read<ThemeProvider>();
|
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -247,6 +245,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rapport de bug autonome : on collecte une description + les infos
|
||||||
|
// techniques, et on copie un rapport prêt à coller dans un email de support.
|
||||||
|
void _showReportBugDialog() {
|
||||||
|
final descController = TextEditingController();
|
||||||
|
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
|
||||||
|
const appVersion = '1.0.0';
|
||||||
|
final platform = Theme.of(context).platform.name;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Signaler un bug'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Décrivez le problème : ce que vous faisiez, ce qui était attendu et ce qui s\'est passé.',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: descController,
|
||||||
|
maxLines: 5,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Ex: l\'application se ferme quand j\'ouvre une session...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Infos techniques jointes : version $appVersion • $platform',
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
|
label: const Text('Copier le rapport'),
|
||||||
|
onPressed: () {
|
||||||
|
final desc = descController.text.trim();
|
||||||
|
final report = StringBuffer()
|
||||||
|
..writeln('--- Rapport de bug ---')
|
||||||
|
..writeln('Version : $appVersion')
|
||||||
|
..writeln('Plateforme : $platform')
|
||||||
|
..writeln('Date : ${DateTime.now().toIso8601String()}')
|
||||||
|
..writeln('')
|
||||||
|
..writeln('Description :')
|
||||||
|
..writeln(desc.isEmpty ? '(non renseignée)' : desc);
|
||||||
|
|
||||||
|
Clipboard.setData(ClipboardData(text: report.toString()));
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Rapport copié — collez-le dans un email à $supportEmail'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -303,7 +374,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
|
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
|
||||||
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
|
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
|
||||||
value: _isUploadEnabled,
|
value: _isUploadEnabled,
|
||||||
activeColor: AppTheme.primaryColor,
|
activeThumbColor: AppTheme.primaryColor,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
|
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
|
||||||
@@ -347,6 +418,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
subtitle: '1.0.0',
|
subtitle: '1.0.0',
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
),
|
),
|
||||||
|
_buildSettingsTile(
|
||||||
|
context: context,
|
||||||
|
icon: Icons.bug_report_outlined,
|
||||||
|
title: 'Signaler un bug',
|
||||||
|
subtitle: 'Aidez-nous à corriger les problèmes',
|
||||||
|
onTap: _showReportBugDialog,
|
||||||
|
),
|
||||||
_buildSettingsTile(
|
_buildSettingsTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.privacy_tip_outlined,
|
icon: Icons.privacy_tip_outlined,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
|
|||||||
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 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
||||||
|
import '../../core/widgets/metric_info_button.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';
|
||||||
@@ -10,7 +11,12 @@ import '../../services/statistics_service.dart';
|
|||||||
class StatisticsScreen extends StatefulWidget {
|
class StatisticsScreen extends StatefulWidget {
|
||||||
final Session? singleSession;
|
final Session? singleSession;
|
||||||
|
|
||||||
const StatisticsScreen({super.key, this.singleSession});
|
/// Incrémenté par la navigation à chaque fois que l'onglet Stats est ouvert,
|
||||||
|
/// pour forcer un rechargement des données (l'écran est gardé vivant par un
|
||||||
|
/// IndexedStack, sinon les filtres arme/distance resteraient périmés).
|
||||||
|
final int refreshTick;
|
||||||
|
|
||||||
|
const StatisticsScreen({super.key, this.singleSession, this.refreshTick = 0});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatisticsScreen> createState() => _StatisticsScreenState();
|
State<StatisticsScreen> createState() => _StatisticsScreenState();
|
||||||
@@ -18,7 +24,7 @@ class StatisticsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _StatisticsScreenState extends State<StatisticsScreen> {
|
class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||||
final StatisticsService _statisticsService = StatisticsService();
|
final StatisticsService _statisticsService = StatisticsService();
|
||||||
StatsPeriod _selectedPeriod = StatsPeriod.all;
|
final StatsPeriod _selectedPeriod = StatsPeriod.all;
|
||||||
SessionStatistics? _statistics;
|
SessionStatistics? _statistics;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
List<Session> _allSessions = [];
|
List<Session> _allSessions = [];
|
||||||
@@ -29,12 +35,30 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
List<String> _availableWeapons = ['Toutes'];
|
List<String> _availableWeapons = ['Toutes'];
|
||||||
List<String> _availableDistances = ['Toutes'];
|
List<String> _availableDistances = ['Toutes'];
|
||||||
|
|
||||||
|
// --- Comparateur de sessions ---
|
||||||
|
// Quand 2 sessions sont sélectionnées, l'écran passe en mode comparaison :
|
||||||
|
// un switch permet d'alterner l'affichage des stats entre la session A et B.
|
||||||
|
Session? _compareA;
|
||||||
|
Session? _compareB;
|
||||||
|
bool _showingB = false; // false = on affiche A, true = on affiche B
|
||||||
|
bool get _compareMode => _compareA != null && _compareB != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(StatisticsScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// L'onglet vient d'être ré-ouvert : on recharge pour rafraîchir les listes
|
||||||
|
// de filtres (armes/distances) et les stats avec les nouvelles sessions.
|
||||||
|
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||||
|
_loadStatistics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadStatistics() async {
|
Future<void> _loadStatistics() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
@@ -79,6 +103,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _calculateStats() {
|
void _calculateStats() {
|
||||||
|
// Mode comparaison : on calcule les stats sur la seule session active
|
||||||
|
// (A ou B selon le switch), sans filtre de période.
|
||||||
|
if (_compareMode) {
|
||||||
|
final active = _showingB ? _compareB! : _compareA!;
|
||||||
|
_statistics = _statisticsService.calculateStatistics(
|
||||||
|
[active],
|
||||||
|
period: StatsPeriod.all,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Filtrer les sessions avant calcul
|
// Filtrer les sessions avant calcul
|
||||||
final filteredSessions = _allSessions.where((s) {
|
final filteredSessions = _allSessions.where((s) {
|
||||||
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
|
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
|
||||||
@@ -92,6 +127,80 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _sessionLabel(Session s) {
|
||||||
|
final d = s.createdAt;
|
||||||
|
final date = '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
|
||||||
|
return '${s.weapon} • $date • ${s.totalScore} pts';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sélection des 2 sessions à comparer via un dialog à deux listes déroulantes.
|
||||||
|
Future<void> _openCompareDialog() async {
|
||||||
|
if (_allSessions.length < 2) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Il faut au moins 2 sessions enregistrées pour comparer.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Session? a = _compareA ?? _allSessions[0];
|
||||||
|
Session? b = _compareB ?? _allSessions[1];
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: const Text('Comparer 2 sessions'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<Session>(
|
||||||
|
initialValue: a,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Session A'),
|
||||||
|
items: _allSessions
|
||||||
|
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => a = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<Session>(
|
||||||
|
initialValue: b,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Session B'),
|
||||||
|
items: _allSessions
|
||||||
|
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => b = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Comparer')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true && a != null && b != null) {
|
||||||
|
setState(() {
|
||||||
|
_compareA = a;
|
||||||
|
_compareB = b;
|
||||||
|
_showingB = false;
|
||||||
|
_calculateStats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitCompareMode() {
|
||||||
|
setState(() {
|
||||||
|
_compareA = null;
|
||||||
|
_compareB = null;
|
||||||
|
_showingB = false;
|
||||||
|
_calculateStats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
List<double> _getScoreHistory() {
|
List<double> _getScoreHistory() {
|
||||||
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
|
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
|
||||||
// Sort sessions by date and take last 10
|
// Sort sessions by date and take last 10
|
||||||
@@ -123,47 +232,64 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
),
|
),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: RefreshIndicator(
|
: Column(
|
||||||
onRefresh: _loadStatistics,
|
children: [
|
||||||
child: SingleChildScrollView(
|
// HEADER COLLANT : filtres + comparateur toujours visibles
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
Material(
|
||||||
padding: const EdgeInsets.all(16),
|
elevation: 2,
|
||||||
child: Column(
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
children: [
|
child: Padding(
|
||||||
// 1. FILTRES (Arme et Distance)
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||||
Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
// 1. FILTRES (Arme et Distance)
|
||||||
child: _buildDropdown(
|
Row(
|
||||||
'Arme utilisée',
|
children: [
|
||||||
_selectedWeapon,
|
Expanded(
|
||||||
_availableWeapons,
|
child: _buildDropdown(
|
||||||
(val) {
|
'Arme utilisée',
|
||||||
setState(() {
|
_selectedWeapon,
|
||||||
_selectedWeapon = val!;
|
_availableWeapons,
|
||||||
_calculateStats();
|
(val) {
|
||||||
});
|
setState(() {
|
||||||
},
|
_selectedWeapon = val!;
|
||||||
|
_calculateStats();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildDropdown(
|
||||||
|
'Distance',
|
||||||
|
_selectedDistance,
|
||||||
|
_availableDistances,
|
||||||
|
(val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDistance = val!;
|
||||||
|
_calculateStats();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildDropdown(
|
|
||||||
'Distance',
|
|
||||||
_selectedDistance,
|
|
||||||
_availableDistances,
|
|
||||||
(val) {
|
|
||||||
setState(() {
|
|
||||||
_selectedDistance = val!;
|
|
||||||
_calculateStats();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
|
// 1bis. COMPARATEUR DE SESSIONS
|
||||||
|
_buildComparator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _loadStatistics,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -191,18 +317,46 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
'Score',
|
'Score',
|
||||||
'${_statistics?.totalScore ?? 0}',
|
'${_statistics?.totalScore ?? 0}',
|
||||||
_getScoreHistory(),
|
_getScoreHistory(),
|
||||||
|
explanations: const [
|
||||||
|
MetricExplanation(
|
||||||
|
'Score',
|
||||||
|
'Total des points marqués sur la période sélectionnée.',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildChartSection(
|
_buildChartSection(
|
||||||
'Précision',
|
'Précision',
|
||||||
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
||||||
_getPrecisionHistory(),
|
_getPrecisionHistory(),
|
||||||
|
explanations: const [
|
||||||
|
MetricExplanation(
|
||||||
|
'Précision',
|
||||||
|
'Proximité moyenne de vos impacts par rapport au centre '
|
||||||
|
'de la cible (calculée sur les distances).',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'À ne pas confondre',
|
||||||
|
'C\'est différent de la « Réussite » affichée dans une '
|
||||||
|
'session, qui se base sur les points marqués. Les deux '
|
||||||
|
'mesurent des choses distinctes, leurs pourcentages '
|
||||||
|
'ne sont donc pas identiques.',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildChartSection(
|
_buildChartSection(
|
||||||
'Groupement moyen',
|
'Étalement moyen',
|
||||||
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%',
|
'${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
|
||||||
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
|
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
|
||||||
|
explanations: const [
|
||||||
|
MetricExplanation(
|
||||||
|
'Étalement moyen',
|
||||||
|
'Étalement moyen de vos groupements : distance entre les '
|
||||||
|
'impacts les plus éloignés, en % de la largeur de '
|
||||||
|
'l\'image. Plus c\'est bas, plus vos tirs sont serrés.',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 25),
|
const SizedBox(height: 25),
|
||||||
@@ -221,10 +375,101 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparateur : bouton pour choisir 2 sessions, puis switch pour alterner.
|
||||||
|
Widget _buildComparator() {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
if (!_compareMode) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _openCompareDialog,
|
||||||
|
icon: const Icon(Icons.compare_arrows),
|
||||||
|
label: const Text('Comparer 2 sessions'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final activeColor = const Color(0xFF1A73E8);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: activeColor.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: activeColor.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.compare_arrows, color: activeColor, size: 18),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Quitter la comparaison',
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
onPressed: _exitCompareMode,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Switch pour alterner entre les 2 sessions.
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'A · ${_sessionLabel(_compareA!)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _showingB ? theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5) : null,
|
||||||
|
fontWeight: _showingB ? FontWeight.normal : FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: _showingB,
|
||||||
|
activeThumbColor: activeColor,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_showingB = v;
|
||||||
|
_calculateStats();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'B · ${_sessionLabel(_compareB!)}',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _showingB ? null : theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5),
|
||||||
|
fontWeight: _showingB ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Affichage : session ${_showingB ? 'B' : 'A'}',
|
||||||
|
style: TextStyle(fontSize: 11, color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,8 +547,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
Widget _buildChartSection(
|
Widget _buildChartSection(
|
||||||
String title,
|
String title,
|
||||||
String value,
|
String value,
|
||||||
List<double> dataPoints,
|
List<double> dataPoints, {
|
||||||
) {
|
List<MetricExplanation>? explanations,
|
||||||
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -314,9 +560,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
title,
|
children: [
|
||||||
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
|
||||||
|
),
|
||||||
|
if (explanations != null) ...[
|
||||||
|
const Spacer(),
|
||||||
|
MetricInfoButton(title: title, explanations: explanations),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'core/theme/theme_provider.dart';
|
import 'core/theme/theme_provider.dart';
|
||||||
import 'data/repositories/session_repository.dart';
|
import 'data/repositories/session_repository.dart';
|
||||||
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/image_processing_service.dart';
|
|
||||||
import 'features/session/session_provider.dart';
|
import 'features/session/session_provider.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
@@ -32,14 +30,6 @@ void main() async {
|
|||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
Provider<ImageProcessingService>(
|
|
||||||
create: (_) => ImageProcessingService(),
|
|
||||||
),
|
|
||||||
Provider<TargetDetectionService>(
|
|
||||||
create: (context) => TargetDetectionService(
|
|
||||||
imageProcessingService: context.read<ImageProcessingService>(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Provider<ScoreCalculatorService>(
|
Provider<ScoreCalculatorService>(
|
||||||
create: (_) => ScoreCalculatorService(),
|
create: (_) => ScoreCalculatorService(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,25 +15,32 @@ class MainNavigationHolder extends StatefulWidget {
|
|||||||
class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
final List<Widget> _screens = [
|
// Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
|
||||||
const HomeScreen(),
|
// rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
|
||||||
const HistoryScreen(),
|
// rafraîchissent pas seuls).
|
||||||
const StatisticsScreen(),
|
int _statsTick = 0;
|
||||||
const WeaponListScreen(),
|
int _historyTick = 0;
|
||||||
];
|
|
||||||
|
|
||||||
void _onItemTapped(int index) {
|
void _onItemTapped(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedIndex = index;
|
_selectedIndex = index;
|
||||||
|
if (index == 1) _historyTick++;
|
||||||
|
if (index == 2) _statsTick++;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final screens = [
|
||||||
|
const HomeScreen(),
|
||||||
|
HistoryScreen(refreshTick: _historyTick),
|
||||||
|
StatisticsScreen(refreshTick: _statsTick),
|
||||||
|
const WeaponListScreen(),
|
||||||
|
];
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: _selectedIndex,
|
index: _selectedIndex,
|
||||||
children: _screens,
|
children: screens,
|
||||||
),
|
),
|
||||||
bottomNavigationBar: Container(
|
bottomNavigationBar: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ class AiExportService {
|
|||||||
required double targetCenterY,
|
required double targetCenterY,
|
||||||
required double targetRadius,
|
required double targetRadius,
|
||||||
required List<Shot> shots,
|
required List<Shot> shots,
|
||||||
|
int distanceMeters = 25,
|
||||||
|
String weaponName = 'Unknown',
|
||||||
String? apiUrl,
|
String? apiUrl,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
@@ -109,8 +111,8 @@ class AiExportService {
|
|||||||
"device_info": deviceData,
|
"device_info": deviceData,
|
||||||
"target_metadata": {
|
"target_metadata": {
|
||||||
"type": targetType.name,
|
"type": targetType.name,
|
||||||
"distance_meters": 25, // Default/placeholder
|
"distance_meters": distanceMeters,
|
||||||
"weapon": "Unknown", // Default/placeholder
|
"weapon": weaponName,
|
||||||
// The backend could extract exact width/height from the image.
|
// The backend could extract exact width/height from the image.
|
||||||
},
|
},
|
||||||
"plotting": {
|
"plotting": {
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
|
||||||
|
|
||||||
/// Résultat du scan de document.
|
|
||||||
class DocumentScanResult {
|
|
||||||
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
|
|
||||||
final String? imagePath;
|
|
||||||
|
|
||||||
/// true si un scan a bien été produit.
|
|
||||||
bool get success => imagePath != null;
|
|
||||||
|
|
||||||
const DocumentScanResult(this.imagePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service qui lance le scanner de documents Google ML Kit.
|
|
||||||
///
|
|
||||||
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
|
|
||||||
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
|
|
||||||
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
|
|
||||||
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
|
|
||||||
///
|
|
||||||
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
|
|
||||||
class DocumentScannerService {
|
|
||||||
/// Lance le scanner et renvoie le chemin de l'image redressée.
|
|
||||||
///
|
|
||||||
/// [pageLimit] est fixé à 1 (une seule cible par scan).
|
|
||||||
/// On désactive l'import galerie ici car la galerie est gérée séparément.
|
|
||||||
///
|
|
||||||
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
|
|
||||||
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
|
|
||||||
/// rehaussement type photocopie) → détruit les couleurs de la cible.
|
|
||||||
/// - `base` ne fait QUE le recadrage + le redressement de perspective
|
|
||||||
/// (mise de face), en conservant l'image en couleurs réelles. C'est
|
|
||||||
/// indispensable pour pouvoir détecter ensuite les impacts.
|
|
||||||
Future<DocumentScanResult> scanTarget() async {
|
|
||||||
final options = DocumentScannerOptions(
|
|
||||||
mode: ScannerMode.base, // redressement seul, sans filtre couleur
|
|
||||||
pageLimit: 1,
|
|
||||||
isGalleryImport: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
final scanner = DocumentScanner(options: options);
|
|
||||||
try {
|
|
||||||
final DocumentScanningResult result = await scanner.scanDocument();
|
|
||||||
final images = result.images;
|
|
||||||
if (images.isEmpty) {
|
|
||||||
return const DocumentScanResult(null);
|
|
||||||
}
|
|
||||||
return DocumentScanResult(images.first);
|
|
||||||
} finally {
|
|
||||||
// Libère les ressources natives du scanner.
|
|
||||||
await scanner.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -55,37 +55,7 @@ void _cropIsolateEntry(_CropParams params) {
|
|||||||
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
|
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OPTIMISATION MAJEURE : pré-réduction avant rotation ───────────────────
|
|
||||||
// La rotation (copyRotate) est l'opération la plus lourde du package `image`
|
|
||||||
// et son coût est proportionnel au nombre de pixels. Comme la sortie finale
|
|
||||||
// ne fait que `outputSize` px, on n'a aucun intérêt à faire pivoter une image
|
|
||||||
// de plusieurs dizaines de mégapixels.
|
|
||||||
//
|
|
||||||
// On réduit donc l'image source à une "taille de travail" juste suffisante
|
|
||||||
// AVANT toute rotation/crop. Le crop garde ~85% du cadre, donc on laisse une
|
|
||||||
// marge : workMax = outputSize / 0.7 couvre largement le besoin sans perte
|
|
||||||
// visible. Les coordonnées de crop étant relatives (0..1), elles restent
|
|
||||||
// valables quelle que soit l'échelle.
|
|
||||||
final int workMax = (params.outputSize / 0.7).round();
|
|
||||||
final int longestSide = math.max(originalImage.width, originalImage.height);
|
|
||||||
if (longestSide > workMax) {
|
|
||||||
if (originalImage.width >= originalImage.height) {
|
|
||||||
originalImage = img.copyResize(
|
|
||||||
originalImage,
|
|
||||||
width: workMax,
|
|
||||||
interpolation: img.Interpolation.linear,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
originalImage = img.copyResize(
|
|
||||||
originalImage,
|
|
||||||
height: workMax,
|
|
||||||
interpolation: img.Interpolation.linear,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
|
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
|
||||||
// (désormais appliquée sur une image déjà réduite → beaucoup plus rapide)
|
|
||||||
if (params.rotationDegrees != 0.0) {
|
if (params.rotationDegrees != 0.0) {
|
||||||
originalImage = img.copyRotate(
|
originalImage = img.copyRotate(
|
||||||
originalImage,
|
originalImage,
|
||||||
@@ -128,11 +98,152 @@ void _cropIsolateEntry(_CropParams params) {
|
|||||||
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir)
|
||||||
|
class _ViewportCropParams {
|
||||||
|
final String sourcePath;
|
||||||
|
final double offsetDx;
|
||||||
|
final double offsetDy;
|
||||||
|
final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain)
|
||||||
|
final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran
|
||||||
|
final double zoomScale; // zoom utilisateur : sert UNIQUEMENT au pointage
|
||||||
|
final double rotationDegrees;
|
||||||
|
final int outputSize;
|
||||||
|
final String outputPath;
|
||||||
|
|
||||||
|
_ViewportCropParams({
|
||||||
|
required this.sourcePath,
|
||||||
|
required this.offsetDx,
|
||||||
|
required this.offsetDy,
|
||||||
|
required this.displayPerSourcePx,
|
||||||
|
required this.cropSizeDisplay,
|
||||||
|
required this.zoomScale,
|
||||||
|
required this.rotationDegrees,
|
||||||
|
required this.outputSize,
|
||||||
|
required this.outputPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation
|
||||||
|
// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute
|
||||||
|
// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur
|
||||||
|
// l'a placée, même collée à un bord (aucun recentrage forcé).
|
||||||
|
void _viewportCropIsolateEntry(_ViewportCropParams p) {
|
||||||
|
final bytes = File(p.sourcePath).readAsBytesSync();
|
||||||
|
final img.Image? src = img.decodeImage(bytes);
|
||||||
|
if (src == null) {
|
||||||
|
throw Exception('Impossible de décoder l\'image: ${p.sourcePath}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels.
|
||||||
|
img.Image rotated = src;
|
||||||
|
if (p.rotationDegrees != 0.0) {
|
||||||
|
rotated = img.copyRotate(
|
||||||
|
src,
|
||||||
|
angle: p.rotationDegrees,
|
||||||
|
interpolation: img.Interpolation.linear,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final double f = p.displayPerSourcePx;
|
||||||
|
// Côté de la fenêtre de visée en pixels source. On utilise f SEUL (pas le
|
||||||
|
// zoom) → le champ de vision capturé est toujours celui de l'image non
|
||||||
|
// zoomée : le zoom n'est donc PAS pris en compte dans le rendu de sortie.
|
||||||
|
final int side = math.max(1, (p.cropSizeDisplay / f).round());
|
||||||
|
|
||||||
|
// Centre de la fenêtre de visée, en pixels de l'image (rotated).
|
||||||
|
// La translation écran s'applique APRÈS le zoom, donc un déplacement écran
|
||||||
|
// `offset` vaut `offset / (f * zoom)` pixels source. On inclut donc le zoom
|
||||||
|
// ICI (uniquement pour le pointage) afin que la croix vise le même point
|
||||||
|
// quel que soit le niveau de zoom.
|
||||||
|
final double fz = f * p.zoomScale;
|
||||||
|
final double cropCenterX = rotated.width / 2 - p.offsetDx / fz;
|
||||||
|
final double cropCenterY = rotated.height / 2 - p.offsetDy / fz;
|
||||||
|
|
||||||
|
final int srcX = (cropCenterX - side / 2).round();
|
||||||
|
final int srcY = (cropCenterY - side / 2).round();
|
||||||
|
|
||||||
|
// Toile carrée NOIRE opaque (numChannels 3 → pixels initialisés à 0 = noir).
|
||||||
|
final out = img.Image(width: side, height: side, numChannels: 3);
|
||||||
|
|
||||||
|
// Intersection de la fenêtre de visée avec l'image réelle. Tout ce qui
|
||||||
|
// déborde reste noir (padding) → aucun recentrage forcé.
|
||||||
|
final int vx0 = math.max(0, srcX);
|
||||||
|
final int vy0 = math.max(0, srcY);
|
||||||
|
final int vx1 = math.min(rotated.width, srcX + side);
|
||||||
|
final int vy1 = math.min(rotated.height, srcY + side);
|
||||||
|
final int vw = vx1 - vx0;
|
||||||
|
final int vh = vy1 - vy0;
|
||||||
|
|
||||||
|
if (vw > 0 && vh > 0) {
|
||||||
|
final region = img.copyCrop(rotated, x: vx0, y: vy0, width: vw, height: vh);
|
||||||
|
// dstW/dstH EXPLICITES = taille de la région → AUCUN redimensionnement
|
||||||
|
// (sinon compositeImage étire la source pour remplir la destination).
|
||||||
|
img.compositeImage(
|
||||||
|
out,
|
||||||
|
region,
|
||||||
|
dstX: vx0 - srcX,
|
||||||
|
dstY: vy0 - srcY,
|
||||||
|
dstW: vw,
|
||||||
|
dstH: vh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
img.Image result = out;
|
||||||
|
if (side != p.outputSize) {
|
||||||
|
result = img.copyResize(
|
||||||
|
out,
|
||||||
|
width: p.outputSize,
|
||||||
|
height: p.outputSize,
|
||||||
|
interpolation: img.Interpolation.linear,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85));
|
||||||
|
}
|
||||||
|
|
||||||
class ImageCropService {
|
class ImageCropService {
|
||||||
final Uuid _uuid = const Uuid();
|
final Uuid _uuid = const Uuid();
|
||||||
|
|
||||||
static const int maxOutputSize = 1024;
|
static const int maxOutputSize = 1024;
|
||||||
|
|
||||||
|
/// Découpe carrée calée sur la fenêtre de visée de l'écran de centrage.
|
||||||
|
/// Le décalage (pan) et la rotation sont respectés, le zoom est ignoré, et
|
||||||
|
/// tout débordement hors de l'image est rempli en noir (jamais de recentrage).
|
||||||
|
///
|
||||||
|
/// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran.
|
||||||
|
/// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain).
|
||||||
|
/// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran.
|
||||||
|
Future<String> cropViewport({
|
||||||
|
required String sourcePath,
|
||||||
|
required double offsetDx,
|
||||||
|
required double offsetDy,
|
||||||
|
required double displayPerSourcePx,
|
||||||
|
required double cropSizeDisplay,
|
||||||
|
double zoomScale = 1.0,
|
||||||
|
double rotationDegrees = 0.0,
|
||||||
|
int outputSize = maxOutputSize,
|
||||||
|
}) async {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
|
||||||
|
|
||||||
|
final params = _ViewportCropParams(
|
||||||
|
sourcePath: sourcePath,
|
||||||
|
offsetDx: offsetDx,
|
||||||
|
offsetDy: offsetDy,
|
||||||
|
displayPerSourcePx: displayPerSourcePx,
|
||||||
|
cropSizeDisplay: cropSizeDisplay,
|
||||||
|
zoomScale: zoomScale <= 0 ? 1.0 : zoomScale,
|
||||||
|
rotationDegrees: rotationDegrees,
|
||||||
|
outputSize: outputSize,
|
||||||
|
outputPath: outputPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Traitement lourd dans un Isolate → thread UI fluide.
|
||||||
|
await Isolate.run(() => _viewportCropIsolateEntry(params));
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> cropToSquare(
|
Future<String> cropToSquare(
|
||||||
String sourcePath,
|
String sourcePath,
|
||||||
CropRect cropRect, {
|
CropRect cropRect, {
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
/// Service de détection d'impacts utilisant OpenCV.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
|
||||||
|
|
||||||
/// Paramètres de détection d'impacts OpenCV
|
|
||||||
class OpenCVDetectionSettings {
|
|
||||||
/// Seuil Canny bas pour la détection de contours
|
|
||||||
final double cannyThreshold1;
|
|
||||||
|
|
||||||
/// Seuil Canny haut pour la détection de contours
|
|
||||||
final double cannyThreshold2;
|
|
||||||
|
|
||||||
/// Distance minimale entre les centres des cercles détectés
|
|
||||||
final double minDist;
|
|
||||||
|
|
||||||
/// Paramètre 1 de HoughCircles (seuil Canny interne)
|
|
||||||
final double param1;
|
|
||||||
|
|
||||||
/// Paramètre 2 de HoughCircles (seuil d'accumulation)
|
|
||||||
final double param2;
|
|
||||||
|
|
||||||
/// Rayon minimum des cercles en pixels
|
|
||||||
final int minRadius;
|
|
||||||
|
|
||||||
/// Rayon maximum des cercles en pixels
|
|
||||||
final int maxRadius;
|
|
||||||
|
|
||||||
/// Taille du flou gaussien (doit être impair)
|
|
||||||
final int blurSize;
|
|
||||||
|
|
||||||
/// Utiliser la détection de contours en plus de Hough
|
|
||||||
final bool useContourDetection;
|
|
||||||
|
|
||||||
/// Circularité minimale pour la détection par contours (0-1)
|
|
||||||
final double minCircularity;
|
|
||||||
|
|
||||||
/// Surface minimale des contours
|
|
||||||
final double minContourArea;
|
|
||||||
|
|
||||||
/// Surface maximale des contours
|
|
||||||
final double maxContourArea;
|
|
||||||
|
|
||||||
const OpenCVDetectionSettings({
|
|
||||||
this.cannyThreshold1 = 50,
|
|
||||||
this.cannyThreshold2 = 150,
|
|
||||||
this.minDist = 20,
|
|
||||||
this.param1 = 100,
|
|
||||||
this.param2 = 30,
|
|
||||||
this.minRadius = 5,
|
|
||||||
this.maxRadius = 50,
|
|
||||||
this.blurSize = 5,
|
|
||||||
this.useContourDetection = true,
|
|
||||||
this.minCircularity = 0.6,
|
|
||||||
this.minContourArea = 50,
|
|
||||||
this.maxContourArea = 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Résultat de détection d'impact
|
|
||||||
class OpenCVDetectedImpact {
|
|
||||||
/// Position X normalisée (0-1)
|
|
||||||
final double x;
|
|
||||||
|
|
||||||
/// Position Y normalisée (0-1)
|
|
||||||
final double y;
|
|
||||||
|
|
||||||
/// Rayon en pixels
|
|
||||||
final double radius;
|
|
||||||
|
|
||||||
/// Score de confiance (0-1)
|
|
||||||
final double confidence;
|
|
||||||
|
|
||||||
/// Méthode de détection utilisée
|
|
||||||
final String method;
|
|
||||||
|
|
||||||
const OpenCVDetectedImpact({
|
|
||||||
required this.x,
|
|
||||||
required this.y,
|
|
||||||
required this.radius,
|
|
||||||
this.confidence = 1.0,
|
|
||||||
this.method = 'unknown',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service de détection d'impacts utilisant OpenCV
|
|
||||||
class OpenCVImpactDetectionService {
|
|
||||||
/// Détecte les impacts dans une image en utilisant OpenCV
|
|
||||||
List<OpenCVDetectedImpact> detectImpacts(
|
|
||||||
String imagePath, {
|
|
||||||
OpenCVDetectionSettings settings = const OpenCVDetectionSettings(),
|
|
||||||
}) {
|
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Détecte les impacts en utilisant une image de référence
|
|
||||||
List<OpenCVDetectedImpact> detectFromReferences(
|
|
||||||
String imagePath,
|
|
||||||
List<({double x, double y})> referencePoints, {
|
|
||||||
double tolerance = 2.0,
|
|
||||||
}) {
|
|
||||||
// Basic implementation: use average color/brightness of reference points
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,23 +26,34 @@ class TargetDetectionResult {
|
|||||||
|
|
||||||
class OpenCVTargetService {
|
class OpenCVTargetService {
|
||||||
/// Detect the main target (center and radius) from an image file
|
/// Detect the main target (center and radius) from an image file
|
||||||
|
///
|
||||||
|
/// IMPORTANT : les Mat OpenCV sont de la mémoire NATIVE, invisible pour le
|
||||||
|
/// garbage collector Dart. Cette méthode est appelée en boucle (~1 s)
|
||||||
|
/// pendant l'aperçu caméra : sans dispose() explicite dans le finally, la
|
||||||
|
/// mémoire native grimpe en continu tant que l'utilisateur vise.
|
||||||
Future<TargetDetectionResult> detectTarget(String imagePath) async {
|
Future<TargetDetectionResult> detectTarget(String imagePath) async {
|
||||||
|
cv.Mat? img;
|
||||||
|
cv.Mat? gray;
|
||||||
|
cv.Mat? blurred;
|
||||||
|
cv.Mat? circles;
|
||||||
|
cv.Mat? looseCircles;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read image
|
// Read image
|
||||||
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
|
img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
|
||||||
if (img.isEmpty) {
|
if (img.isEmpty) {
|
||||||
return TargetDetectionResult.failure();
|
return TargetDetectionResult.failure();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to grayscale
|
// Convert to grayscale
|
||||||
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
|
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
|
||||||
|
|
||||||
// Apply Gaussian blur to reduce noise
|
// Apply Gaussian blur to reduce noise
|
||||||
final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
|
blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
|
||||||
|
|
||||||
// Detect circles using Hough Transform
|
// Detect circles using Hough Transform.
|
||||||
// Parameters need to be tuned for the specific target type
|
// HoughCircles returns a Mat of shape (1, N) of Vec3f (x, y, r).
|
||||||
final circles = cv.HoughCircles(
|
circles = cv.HoughCircles(
|
||||||
blurred,
|
blurred,
|
||||||
cv.HOUGH_GRADIENT,
|
cv.HOUGH_GRADIENT,
|
||||||
1, // dp
|
1, // dp
|
||||||
@@ -55,26 +66,9 @@ class OpenCVTargetService {
|
|||||||
maxRadius: img.cols ~/ 2,
|
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) {
|
if (circles.isEmpty) {
|
||||||
// Try with different parameters if first attempt fails (more lenient)
|
// Try with different parameters if first attempt fails (more lenient)
|
||||||
final looseCircles = cv.HoughCircles(
|
looseCircles = cv.HoughCircles(
|
||||||
blurred,
|
blurred,
|
||||||
cv.HOUGH_GRADIENT,
|
cv.HOUGH_GRADIENT,
|
||||||
1,
|
1,
|
||||||
@@ -93,8 +87,15 @@ class OpenCVTargetService {
|
|||||||
|
|
||||||
return _findBestConcentricCircles(circles, img.cols, img.rows);
|
return _findBestConcentricCircles(circles, img.cols, img.rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// print('Error detecting target with OpenCV: $e');
|
|
||||||
return TargetDetectionResult.failure();
|
return TargetDetectionResult.failure();
|
||||||
|
} finally {
|
||||||
|
// _findBestConcentricCircles a déjà extrait les données dans des listes
|
||||||
|
// Dart avant qu'on arrive ici : libérer les Mat est donc toujours sûr.
|
||||||
|
img?.dispose();
|
||||||
|
gray?.dispose();
|
||||||
|
blurred?.dispose();
|
||||||
|
circles?.dispose();
|
||||||
|
looseCircles?.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ class ParallelismService {
|
|||||||
|
|
||||||
// Normalisation par la magnitude réelle (indépendant de g exact)
|
// Normalisation par la magnitude réelle (indépendant de g exact)
|
||||||
final double nx = gx / magnitude;
|
final double nx = gx / magnitude;
|
||||||
final double ny = gy / magnitude;
|
|
||||||
final double nz = gz / magnitude;
|
final double nz = gz / magnitude;
|
||||||
|
|
||||||
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../data/models/target_type.dart';
|
|
||||||
import 'image_processing_service.dart';
|
|
||||||
import 'opencv_impact_detection_service.dart';
|
|
||||||
export 'image_processing_service.dart'
|
|
||||||
show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
|
|
||||||
export 'opencv_impact_detection_service.dart'
|
|
||||||
show OpenCVDetectionSettings, OpenCVDetectedImpact;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// STRUCT ET FONCTION GLOBALE POUR LE PARALLÉLISME (THREAD SECONDAIRE)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Conteneur de données pour envoyer les paramètres à l'Isolate d'arrière-plan.
|
|
||||||
class DetectionPayload {
|
|
||||||
final String imagePath;
|
|
||||||
final TargetType targetType;
|
|
||||||
|
|
||||||
// On recrée les instances à l'intérieur du thread isolé car les objets ne se partagent pas entre threads.
|
|
||||||
DetectionPayload({
|
|
||||||
required this.imagePath,
|
|
||||||
required this.targetType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FONCTION EXÉCUTÉE EN PARALLÈLE : Tourne sur un autre cœur du processeur.
|
|
||||||
/// L'interface graphique reste à 120 FPS et totalement fluide.
|
|
||||||
TargetDetectionResult runParallelTargetDetection(DetectionPayload payload) {
|
|
||||||
// 1. Initialisation locale des services dans le sous-thread
|
|
||||||
final imageProcessingService = ImageProcessingService();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 2. Détection de la cible principale (Calcul lourd)
|
|
||||||
final mainTarget = imageProcessingService.detectMainTarget(payload.imagePath);
|
|
||||||
|
|
||||||
double centerX = 0.5;
|
|
||||||
double centerY = 0.5;
|
|
||||||
double radius = 0.4;
|
|
||||||
|
|
||||||
if (mainTarget != null) {
|
|
||||||
centerX = mainTarget.centerX;
|
|
||||||
centerY = mainTarget.centerY;
|
|
||||||
radius = mainTarget.radius;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Détection des impacts (Calcul lourd)
|
|
||||||
final impacts = imageProcessingService.detectImpacts(payload.imagePath);
|
|
||||||
|
|
||||||
// 4. Calcul mathématique des scores relatifs
|
|
||||||
final detectedImpacts = impacts.map((impact) {
|
|
||||||
final score = payload.targetType == TargetType.concentric
|
|
||||||
? _staticCalculateConcentricScore(
|
|
||||||
impact.x,
|
|
||||||
impact.y,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
radius,
|
|
||||||
)
|
|
||||||
: _staticCalculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
|
||||||
|
|
||||||
return DetectedImpactResult(
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
radius: impact.radius,
|
|
||||||
suggestedScore: score,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return TargetDetectionResult(
|
|
||||||
centerX: centerX,
|
|
||||||
centerY: centerY,
|
|
||||||
radius: radius,
|
|
||||||
impacts: detectedImpacts,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
return TargetDetectionResult.error('Erreur de detection parallèle: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fonctions mathématiques pures nécessaires à l'Isolate (statiques)
|
|
||||||
int _staticCalculateConcentricScore(double impactX, double impactY, double centerX, double centerY, double targetRadius) {
|
|
||||||
final dx = impactX - centerX;
|
|
||||||
final dy = impactY - centerY;
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
|
||||||
if (distance <= 0.1) return 10;
|
|
||||||
if (distance <= 0.2) return 9;
|
|
||||||
if (distance <= 0.3) return 8;
|
|
||||||
if (distance <= 0.4) return 7;
|
|
||||||
if (distance <= 0.5) return 6;
|
|
||||||
if (distance <= 0.6) return 5;
|
|
||||||
if (distance <= 0.7) return 4;
|
|
||||||
if (distance <= 0.8) return 3;
|
|
||||||
if (distance <= 0.9) return 2;
|
|
||||||
if (distance <= 1.0) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _staticCalculateSilhouetteScore(double impactX, double impactY, double centerX, double centerY) {
|
|
||||||
final dx = (impactX - centerX).abs();
|
|
||||||
final dy = impactY - centerY;
|
|
||||||
if (dx > 0.15) return 0;
|
|
||||||
if (dy < -0.25) return 5;
|
|
||||||
if (dy < 0.0) return 5;
|
|
||||||
if (dy < 0.15) return 4;
|
|
||||||
if (dy < 0.35) return 3;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FIN DU BLOC DE PARALLÉLISME
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class TargetDetectionResult {
|
|
||||||
final double centerX; // Relative (0-1)
|
|
||||||
final double centerY; // Relative (0-1)
|
|
||||||
final double radius; // Relative (0-1)
|
|
||||||
final List<DetectedImpactResult> impacts;
|
|
||||||
final bool success;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
TargetDetectionResult({
|
|
||||||
required this.centerX,
|
|
||||||
required this.centerY,
|
|
||||||
required this.radius,
|
|
||||||
required this.impacts,
|
|
||||||
this.success = true,
|
|
||||||
this.errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory TargetDetectionResult.error(String message) {
|
|
||||||
return TargetDetectionResult(
|
|
||||||
centerX: 0.5,
|
|
||||||
centerY: 0.5,
|
|
||||||
radius: 0.4,
|
|
||||||
impacts: [],
|
|
||||||
success: false,
|
|
||||||
errorMessage: message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DetectedImpactResult {
|
|
||||||
final double x; // Relative (0-1)
|
|
||||||
final double y; // Relative (0-1)
|
|
||||||
final double radius; // Absolute pixels
|
|
||||||
final int suggestedScore;
|
|
||||||
|
|
||||||
DetectedImpactResult({
|
|
||||||
required this.x,
|
|
||||||
required this.y,
|
|
||||||
required this.radius,
|
|
||||||
required this.suggestedScore,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class TargetDetectionService {
|
|
||||||
final ImageProcessingService _imageProcessingService;
|
|
||||||
final OpenCVImpactDetectionService _opencvService;
|
|
||||||
|
|
||||||
TargetDetectionService({
|
|
||||||
ImageProcessingService? imageProcessingService,
|
|
||||||
OpenCVImpactDetectionService? opencvService,
|
|
||||||
}) : _imageProcessingService =
|
|
||||||
imageProcessingService ?? ImageProcessingService(),
|
|
||||||
_opencvService = opencvService ?? OpenCVImpactDetectionService();
|
|
||||||
|
|
||||||
/// Detect target and impacts from an image file ASYNCHRONOUSLY in a separate Thread.
|
|
||||||
/// CORRECTION : Utilise désormais 'compute' pour basculer en arrière-plan immédiat.
|
|
||||||
Future<TargetDetectionResult> detectTargetAsync(String imagePath, TargetType targetType) async {
|
|
||||||
final payload = DetectionPayload(imagePath: imagePath, targetType: targetType);
|
|
||||||
// Déclenche l'exécution isolée en tâche de fond
|
|
||||||
return await compute(runParallelTargetDetection, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gardée pour rétrocompatibilité synchrone si nécessaire
|
|
||||||
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
|
|
||||||
try {
|
|
||||||
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
|
|
||||||
|
|
||||||
double centerX = 0.5;
|
|
||||||
double centerY = 0.5;
|
|
||||||
double radius = 0.4;
|
|
||||||
|
|
||||||
if (mainTarget != null) {
|
|
||||||
centerX = mainTarget.centerX;
|
|
||||||
centerY = mainTarget.centerY;
|
|
||||||
radius = mainTarget.radius;
|
|
||||||
}
|
|
||||||
|
|
||||||
final impacts = _imageProcessingService.detectImpacts(imagePath);
|
|
||||||
|
|
||||||
final detectedImpacts = impacts.map((impact) {
|
|
||||||
final score = targetType == TargetType.concentric
|
|
||||||
? _calculateConcentricScore(
|
|
||||||
impact.x,
|
|
||||||
impact.y,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
radius,
|
|
||||||
)
|
|
||||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
|
||||||
|
|
||||||
return DetectedImpactResult(
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
radius: impact.radius,
|
|
||||||
suggestedScore: score,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return TargetDetectionResult(
|
|
||||||
centerX: centerX,
|
|
||||||
centerY: centerY,
|
|
||||||
radius: radius,
|
|
||||||
impacts: detectedImpacts,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
return TargetDetectionResult.error('Erreur de detection: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int _calculateConcentricScore(
|
|
||||||
double impactX,
|
|
||||||
double impactY,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double targetRadius,
|
|
||||||
) {
|
|
||||||
final dx = impactX - centerX;
|
|
||||||
final dy = impactY - centerY;
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
|
||||||
|
|
||||||
if (distance <= 0.1) return 10;
|
|
||||||
if (distance <= 0.2) return 9;
|
|
||||||
if (distance <= 0.3) return 8;
|
|
||||||
if (distance <= 0.4) return 7;
|
|
||||||
if (distance <= 0.5) return 6;
|
|
||||||
if (distance <= 0.6) return 5;
|
|
||||||
if (distance <= 0.7) return 4;
|
|
||||||
if (distance <= 0.8) return 3;
|
|
||||||
if (distance <= 0.9) return 2;
|
|
||||||
if (distance <= 1.0) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _calculateSilhouetteScore(
|
|
||||||
double impactX,
|
|
||||||
double impactY,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
) {
|
|
||||||
final dx = (impactX - centerX).abs();
|
|
||||||
final dy = impactY - centerY;
|
|
||||||
|
|
||||||
if (dx > 0.15) return 0;
|
|
||||||
if (dy < -0.25) return 5;
|
|
||||||
if (dy < 0.0) return 5;
|
|
||||||
if (dy < 0.15) return 4;
|
|
||||||
if (dy < 0.35) return 3;
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<DetectedImpactResult> detectImpactsOnly(
|
|
||||||
String imagePath,
|
|
||||||
TargetType targetType,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double radius,
|
|
||||||
int ringCount,
|
|
||||||
ImpactDetectionSettings settings,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
final impacts = _imageProcessingService.detectImpactsWithSettings(
|
|
||||||
imagePath,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
|
|
||||||
return impacts.map((impact) {
|
|
||||||
final score = targetType == TargetType.concentric
|
|
||||||
? _calculateConcentricScoreWithRings(
|
|
||||||
impact.x,
|
|
||||||
impact.y,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
radius,
|
|
||||||
ringCount,
|
|
||||||
)
|
|
||||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
|
||||||
|
|
||||||
return DetectedImpactResult(
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
radius: impact.radius,
|
|
||||||
suggestedScore: score,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
} catch (e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int _calculateConcentricScoreWithRings(
|
|
||||||
double impactX,
|
|
||||||
double impactY,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double targetRadius,
|
|
||||||
int ringCount,
|
|
||||||
) {
|
|
||||||
final dx = impactX - centerX;
|
|
||||||
final dy = impactY - centerY;
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
|
||||||
|
|
||||||
for (int i = 0; i < ringCount; i++) {
|
|
||||||
final zoneRadius = (i + 1) / ringCount;
|
|
||||||
if (distance <= zoneRadius) {
|
|
||||||
return 10 - i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImpactCharacteristics? analyzeReferenceImpacts(
|
|
||||||
String imagePath,
|
|
||||||
List<ReferenceImpact> references,
|
|
||||||
) {
|
|
||||||
return _imageProcessingService.analyzeReferenceImpacts(
|
|
||||||
imagePath,
|
|
||||||
references,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<DetectedImpactResult> detectImpactsFromReferences(
|
|
||||||
String imagePath,
|
|
||||||
TargetType targetType,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double radius,
|
|
||||||
int ringCount,
|
|
||||||
ImpactCharacteristics characteristics, {
|
|
||||||
double tolerance = 2.0,
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
final impacts = _imageProcessingService.detectImpactsFromReferences(
|
|
||||||
imagePath,
|
|
||||||
characteristics,
|
|
||||||
tolerance: tolerance,
|
|
||||||
);
|
|
||||||
|
|
||||||
return impacts.map((impact) {
|
|
||||||
final score = targetType == TargetType.concentric
|
|
||||||
? _calculateConcentricScoreWithRings(
|
|
||||||
impact.x,
|
|
||||||
impact.y,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
radius,
|
|
||||||
ringCount,
|
|
||||||
)
|
|
||||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
|
||||||
|
|
||||||
return DetectedImpactResult(
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
radius: impact.radius,
|
|
||||||
suggestedScore: score,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
} catch (e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<DetectedImpactResult> detectImpactsWithOpenCV(
|
|
||||||
String imagePath,
|
|
||||||
TargetType targetType,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double radius,
|
|
||||||
int ringCount, {
|
|
||||||
OpenCVDetectionSettings? settings,
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
final impacts = _opencvService.detectImpacts(
|
|
||||||
imagePath,
|
|
||||||
settings: settings ?? const OpenCVDetectionSettings(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return impacts.map((impact) {
|
|
||||||
final score = targetType == TargetType.concentric
|
|
||||||
? _calculateConcentricScoreWithRings(
|
|
||||||
impact.x,
|
|
||||||
impact.y,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
radius,
|
|
||||||
ringCount,
|
|
||||||
)
|
|
||||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
|
||||||
|
|
||||||
return DetectedImpactResult(
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
radius: impact.radius,
|
|
||||||
suggestedScore: score,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Erreur détection OpenCV: $e');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
|
|
||||||
String imagePath,
|
|
||||||
TargetType targetType,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double radius,
|
|
||||||
int ringCount,
|
|
||||||
List<ReferenceImpact> references, {
|
|
||||||
double tolerance = 2.0,
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
|
|
||||||
|
|
||||||
final impacts = _opencvService.detectFromReferences(
|
|
||||||
imagePath,
|
|
||||||
refPoints,
|
|
||||||
tolerance: tolerance,
|
|
||||||
);
|
|
||||||
|
|
||||||
return impacts.map((impact) {
|
|
||||||
final score = targetType == TargetType.concentric
|
|
||||||
? _calculateConcentricScoreWithRings(
|
|
||||||
impact.x,
|
|
||||||
impact.y,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
radius,
|
|
||||||
ringCount,
|
|
||||||
)
|
|
||||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
|
||||||
|
|
||||||
return DetectedImpactResult(
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
radius: impact.radius,
|
|
||||||
suggestedScore: score,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Erreur détection OpenCV depuis références: $e');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
|
||||||
|
|
||||||
/// Résultat d'une tentative de redressement de cible.
|
|
||||||
class RectifyResult {
|
|
||||||
/// Chemin du fichier image redressé (ou original si échec).
|
|
||||||
final String outputPath;
|
|
||||||
|
|
||||||
/// true si une cible a été détectée et redressée, false si on a renvoyé
|
|
||||||
/// l'image d'origine sans transformation.
|
|
||||||
final bool rectified;
|
|
||||||
|
|
||||||
/// Angle d'inclinaison estimé de la cible AVANT redressement, en degrés.
|
|
||||||
/// (0 = déjà de face). Utile pour informer l'utilisateur.
|
|
||||||
final double estimatedTiltDegrees;
|
|
||||||
|
|
||||||
/// Message de diagnostic (utile pour debug / affichage).
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
const RectifyResult({
|
|
||||||
required this.outputPath,
|
|
||||||
required this.rectified,
|
|
||||||
required this.estimatedTiltDegrees,
|
|
||||||
required this.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service qui redresse une cible RONDE (cercles concentriques) photographiée
|
|
||||||
/// de biais, en la ramenant parfaitement de face.
|
|
||||||
///
|
|
||||||
/// Principe :
|
|
||||||
/// 1. Détecter le plus grand contour ~circulaire de l'image.
|
|
||||||
/// 2. Ajuster une ELLIPSE sur ce contour (fitEllipse).
|
|
||||||
/// 3. Un cercle vu en perspective devient une ellipse : on calcule la
|
|
||||||
/// transformation de perspective qui remappe cette ellipse vers un
|
|
||||||
/// CERCLE parfait, et on l'applique à toute l'image.
|
|
||||||
/// 4. La cible apparaît alors de face.
|
|
||||||
///
|
|
||||||
/// L'image résultat est carrée et centrée sur la cible.
|
|
||||||
class TargetRectifyService {
|
|
||||||
/// Taille (en pixels) du côté de l'image carrée de sortie.
|
|
||||||
final int outputSize;
|
|
||||||
|
|
||||||
/// Marge autour de la cible dans l'image de sortie (1.0 = cible pile au bord,
|
|
||||||
/// 1.3 = 30 % de marge autour). Garde un peu de contexte.
|
|
||||||
final double marginFactor;
|
|
||||||
|
|
||||||
/// En dessous de cet écart d'axes (ratio petit/grand axe proche de 1),
|
|
||||||
/// la cible est considérée déjà de face → pas de warp inutile.
|
|
||||||
final double minTiltRatioToRectify;
|
|
||||||
|
|
||||||
TargetRectifyService({
|
|
||||||
this.outputSize = 1024,
|
|
||||||
this.marginFactor = 1.25,
|
|
||||||
this.minTiltRatioToRectify = 0.985,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Redresse l'image située à [inputPath]. Écrit le résultat dans
|
|
||||||
/// [outputPath] et renvoie un [RectifyResult].
|
|
||||||
///
|
|
||||||
/// Ne bloque jamais : en cas d'échec de détection, renvoie l'image
|
|
||||||
/// d'origine (rectified = false) pour ne pas perdre la photo du tireur.
|
|
||||||
Future<RectifyResult> rectify({
|
|
||||||
required String inputPath,
|
|
||||||
required String outputPath,
|
|
||||||
}) async {
|
|
||||||
cv.Mat? src;
|
|
||||||
cv.Mat? gray;
|
|
||||||
cv.Mat? blurred;
|
|
||||||
cv.Mat? edges;
|
|
||||||
try {
|
|
||||||
src = cv.imread(inputPath, flags: cv.IMREAD_COLOR);
|
|
||||||
if (src.isEmpty) {
|
|
||||||
return RectifyResult(
|
|
||||||
outputPath: inputPath,
|
|
||||||
rectified: false,
|
|
||||||
estimatedTiltDegrees: 0,
|
|
||||||
message: 'Image illisible',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1. Prétraitement ────────────────────────────────────────────────
|
|
||||||
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
|
|
||||||
blurred = cv.gaussianBlur(gray, (5, 5), 2, sigmaY: 2);
|
|
||||||
edges = cv.canny(blurred, 60, 160);
|
|
||||||
|
|
||||||
// Dilatation légère pour fermer les contours brisés
|
|
||||||
final cv.Mat kernel = cv.getStructuringElement(
|
|
||||||
cv.MORPH_ELLIPSE,
|
|
||||||
(3, 3),
|
|
||||||
);
|
|
||||||
final cv.Mat dilated = cv.dilate(edges, kernel);
|
|
||||||
|
|
||||||
// ── 2. Recherche du meilleur contour elliptique ──────────────────────
|
|
||||||
final (contours, _) = cv.findContours(
|
|
||||||
dilated,
|
|
||||||
cv.RETR_EXTERNAL,
|
|
||||||
cv.CHAIN_APPROX_SIMPLE,
|
|
||||||
);
|
|
||||||
|
|
||||||
final double imgArea = (src.width * src.height).toDouble();
|
|
||||||
cv.RotatedRect? bestEllipse;
|
|
||||||
double bestScore = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < contours.length; i++) {
|
|
||||||
final c = contours[i];
|
|
||||||
if (c.length < 5) continue; // fitEllipse exige >= 5 points
|
|
||||||
|
|
||||||
final double area = cv.contourArea(c);
|
|
||||||
// On ignore les contours minuscules et ceux qui couvrent presque tout
|
|
||||||
if (area < imgArea * 0.03 || area > imgArea * 0.97) continue;
|
|
||||||
|
|
||||||
final cv.RotatedRect e = cv.fitEllipse(c);
|
|
||||||
final ep = e.points; // 4 sommets
|
|
||||||
if (ep.length < 4) continue;
|
|
||||||
final double ecx = (ep[0].x + ep[1].x + ep[2].x + ep[3].x) / 4.0;
|
|
||||||
final double ecy = (ep[0].y + ep[1].y + ep[2].y + ep[3].y) / 4.0;
|
|
||||||
final double mAx = (ep[0].x + ep[1].x) / 2.0;
|
|
||||||
final double mAy = (ep[0].y + ep[1].y) / 2.0;
|
|
||||||
final double mBx = (ep[1].x + ep[2].x) / 2.0;
|
|
||||||
final double mBy = (ep[1].y + ep[2].y) / 2.0;
|
|
||||||
final double w =
|
|
||||||
2 * math.sqrt(math.pow(mAx - ecx, 2) + math.pow(mAy - ecy, 2));
|
|
||||||
final double h =
|
|
||||||
2 * math.sqrt(math.pow(mBx - ecx, 2) + math.pow(mBy - ecy, 2));
|
|
||||||
if (w <= 1 || h <= 1) continue;
|
|
||||||
|
|
||||||
// À quel point le contour ressemble-t-il vraiment à son ellipse ?
|
|
||||||
// On compare l'aire du contour à l'aire de l'ellipse ajustée.
|
|
||||||
final double ellipseArea = math.pi * (w / 2) * (h / 2);
|
|
||||||
if (ellipseArea <= 0) continue;
|
|
||||||
final double fitRatio = area / ellipseArea; // ~1 si bon ajustement
|
|
||||||
if (fitRatio < 0.7 || fitRatio > 1.3) continue;
|
|
||||||
|
|
||||||
// Score = taille de l'ellipse (on veut la cible la plus grande)
|
|
||||||
final double score = ellipseArea;
|
|
||||||
if (score > bestScore) {
|
|
||||||
bestScore = score;
|
|
||||||
bestEllipse = e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestEllipse == null) {
|
|
||||||
return RectifyResult(
|
|
||||||
outputPath: inputPath,
|
|
||||||
rectified: false,
|
|
||||||
estimatedTiltDegrees: 0,
|
|
||||||
message: 'Aucune cible circulaire détectée',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. Extraire les 4 sommets de l'ellipse (robuste à la version d'API) ─
|
|
||||||
// RotatedRect.points renvoie les 4 coins de la boîte englobant l'ellipse.
|
|
||||||
// On en dérive nous-mêmes le centre, les demi-axes et l'orientation, ce
|
|
||||||
// qui évite de dépendre de la forme exacte de `.size` / `.center`
|
|
||||||
// (record vs objet) qui varie selon les versions d'opencv_dart.
|
|
||||||
final pts = bestEllipse.points; // List<Point2f> de 4 sommets
|
|
||||||
|
|
||||||
double px(int i) => pts[i].x;
|
|
||||||
double py(int i) => pts[i].y;
|
|
||||||
|
|
||||||
// Centre = moyenne des 4 sommets
|
|
||||||
final double cx = (px(0) + px(1) + px(2) + px(3)) / 4.0;
|
|
||||||
final double cy = (py(0) + py(1) + py(2) + py(3)) / 4.0;
|
|
||||||
|
|
||||||
// Milieux de deux côtés adjacents → extrémités des deux demi-axes
|
|
||||||
// Côté 0-1 et côté 1-2 (ordre des sommets d'un RotatedRect)
|
|
||||||
final double m01x = (px(0) + px(1)) / 2.0;
|
|
||||||
final double m01y = (py(0) + py(1)) / 2.0;
|
|
||||||
final double m12x = (px(1) + px(2)) / 2.0;
|
|
||||||
final double m12y = (py(1) + py(2)) / 2.0;
|
|
||||||
|
|
||||||
// Demi-axes = distance centre → milieu de chaque côté
|
|
||||||
final double axisA =
|
|
||||||
math.sqrt(math.pow(m01x - cx, 2) + math.pow(m01y - cy, 2));
|
|
||||||
final double axisB =
|
|
||||||
math.sqrt(math.pow(m12x - cx, 2) + math.pow(m12y - cy, 2));
|
|
||||||
|
|
||||||
final double majorAxis = math.max(axisA, axisB);
|
|
||||||
final double minorAxis = math.min(axisA, axisB);
|
|
||||||
if (majorAxis <= 1) {
|
|
||||||
return RectifyResult(
|
|
||||||
outputPath: inputPath,
|
|
||||||
rectified: false,
|
|
||||||
estimatedTiltDegrees: 0,
|
|
||||||
message: 'Ellipse dégénérée',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final double axisRatio = minorAxis / majorAxis; // 1 = cercle parfait
|
|
||||||
final double tiltDeg =
|
|
||||||
math.acos(axisRatio.clamp(0.0, 1.0)) * (180.0 / math.pi);
|
|
||||||
|
|
||||||
// Déjà quasiment de face → on ne touche pas (évite le flou inutile)
|
|
||||||
if (axisRatio >= minTiltRatioToRectify) {
|
|
||||||
cv.imwrite(outputPath, src);
|
|
||||||
return RectifyResult(
|
|
||||||
outputPath: outputPath,
|
|
||||||
rectified: false,
|
|
||||||
estimatedTiltDegrees: tiltDeg,
|
|
||||||
message: 'Cible déjà de face',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. Construire la transformation de perspective ────────────────────
|
|
||||||
// Source : extrémités des deux axes de l'ellipse (4 points).
|
|
||||||
// Destination : extrémités des axes d'un cercle parfait centré.
|
|
||||||
// On obtient les extrémités en prolongeant centre→milieu-de-côté.
|
|
||||||
final srcPts = cv.VecPoint.fromList([
|
|
||||||
cv.Point((cx + (m01x - cx)).round(), (cy + (m01y - cy)).round()),
|
|
||||||
cv.Point((cx - (m01x - cx)).round(), (cy - (m01y - cy)).round()),
|
|
||||||
cv.Point((cx + (m12x - cx)).round(), (cy + (m12y - cy)).round()),
|
|
||||||
cv.Point((cx - (m12x - cx)).round(), (cy - (m12y - cy)).round()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Cible : cercle parfait centré, rayon R, dans une image carrée.
|
|
||||||
// L'axe "A" (m01) devient l'axe horizontal, l'axe "B" (m12) le vertical.
|
|
||||||
final double out = outputSize.toDouble();
|
|
||||||
final double center = out / 2;
|
|
||||||
final double radius = (out / 2) / marginFactor;
|
|
||||||
final dstPts = cv.VecPoint.fromList([
|
|
||||||
cv.Point((center + radius).round(), center.round()),
|
|
||||||
cv.Point((center - radius).round(), center.round()),
|
|
||||||
cv.Point(center.round(), (center + radius).round()),
|
|
||||||
cv.Point(center.round(), (center - radius).round()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
final cv.Mat transform = cv.getPerspectiveTransform(srcPts, dstPts);
|
|
||||||
|
|
||||||
final cv.Mat warped = cv.warpPerspective(
|
|
||||||
src,
|
|
||||||
transform,
|
|
||||||
(outputSize, outputSize),
|
|
||||||
flags: cv.INTER_LINEAR,
|
|
||||||
borderMode: cv.BORDER_CONSTANT,
|
|
||||||
);
|
|
||||||
|
|
||||||
cv.imwrite(outputPath, warped);
|
|
||||||
|
|
||||||
return RectifyResult(
|
|
||||||
outputPath: outputPath,
|
|
||||||
rectified: true,
|
|
||||||
estimatedTiltDegrees: tiltDeg,
|
|
||||||
message: 'Cible redressée (inclinaison ${tiltDeg.toStringAsFixed(1)}°)',
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('TargetRectify erreur: $e');
|
|
||||||
// En cas de pépin, on renvoie l'original pour ne jamais perdre la photo
|
|
||||||
return RectifyResult(
|
|
||||||
outputPath: inputPath,
|
|
||||||
rectified: false,
|
|
||||||
estimatedTiltDegrees: 0,
|
|
||||||
message: 'Erreur de traitement: $e',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
pubspec.lock
@@ -293,30 +293,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.3"
|
||||||
google_mlkit_commons:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_mlkit_commons
|
|
||||||
sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.11.1"
|
|
||||||
google_mlkit_document_scanner:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_mlkit_document_scanner
|
|
||||||
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.1"
|
|
||||||
google_mlkit_object_detection:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_mlkit_object_detection
|
|
||||||
sha256: "9dd35886972e18747e22098f8ebee78d30716a99a789bb2e3a65a24229e031e7"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.15.1"
|
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: bully
|
name: bully
|
||||||
description: "A new Flutter project."
|
description: "Application d'analyse de cibles de tir : capture, centrage, plotting manuel des impacts, scores et statistiques."
|
||||||
# The following line prevents the package from being accidentally published to
|
# The following line prevents the package from being accidentally published to
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
@@ -38,11 +38,9 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
sensors_plus: ^4.0.2
|
sensors_plus: ^4.0.2
|
||||||
opencv_dart: ^2.1.0
|
opencv_dart: ^2.1.0
|
||||||
google_mlkit_object_detection: ^0.15.0
|
|
||||||
|
|
||||||
# Image capture from camera/gallery
|
# Image capture from camera/gallery
|
||||||
image_picker: ^1.2.1
|
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
|
||||||
@@ -73,9 +71,6 @@ dependencies:
|
|||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
camera: ^0.12.0+1
|
camera: ^0.12.0+1
|
||||||
|
|
||||||
# Machine Learning for YOLOv8
|
|
||||||
# tflite_flutter: ^0.11.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@@ -100,7 +95,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
60
test/services/grouping_analyzer_service_test.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:bully/data/models/shot.dart';
|
||||||
|
import 'package:bully/services/grouping_analyzer_service.dart';
|
||||||
|
|
||||||
|
Shot _shot(String id, double x, double y) =>
|
||||||
|
Shot(id: id, x: x, y: y, score: 0, analysisId: '');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final service = GroupingAnalyzerService();
|
||||||
|
|
||||||
|
group('analyzeGrouping', () {
|
||||||
|
test('liste vide → résultat vide', () {
|
||||||
|
final result = service.analyzeGrouping([]);
|
||||||
|
expect(result.shotCount, 0);
|
||||||
|
expect(result.diameter, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un seul tir → diamètre nul, centre sur le tir', () {
|
||||||
|
final result = service.analyzeGrouping([_shot('a', 0.3, 0.7)]);
|
||||||
|
expect(result.shotCount, 1);
|
||||||
|
expect(result.centerX, 0.3);
|
||||||
|
expect(result.centerY, 0.7);
|
||||||
|
expect(result.diameter, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deux tirs → centroïde au milieu et diamètre = écart', () {
|
||||||
|
final result = service.analyzeGrouping([
|
||||||
|
_shot('a', 0.4, 0.5),
|
||||||
|
_shot('b', 0.6, 0.5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.centerX, closeTo(0.5, 1e-9));
|
||||||
|
expect(result.centerY, closeTo(0.5, 1e-9));
|
||||||
|
expect(result.diameter, closeTo(0.2, 1e-9));
|
||||||
|
expect(result.meanRadius, closeTo(0.1, 1e-9));
|
||||||
|
// Deux tirs équidistants du centroïde → dispersion nulle.
|
||||||
|
expect(result.standardDeviation, closeTo(0.0, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le diamètre est la plus grande distance entre deux tirs', () {
|
||||||
|
final result = service.analyzeGrouping([
|
||||||
|
_shot('a', 0.2, 0.5),
|
||||||
|
_shot('b', 0.5, 0.5),
|
||||||
|
_shot('c', 0.8, 0.5),
|
||||||
|
]);
|
||||||
|
expect(result.diameter, closeTo(0.6, 1e-9));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getOffsetDescription', () {
|
||||||
|
test('décalage négligeable → Centre', () {
|
||||||
|
expect(service.getOffsetDescription(0.01, -0.01), 'Centre');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('décalage combiné → direction composée', () {
|
||||||
|
expect(service.getOffsetDescription(0.1, -0.1), 'Haut-Droite');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
110
test/services/score_calculator_service_test.dart
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:bully/data/models/shot.dart';
|
||||||
|
import 'package:bully/data/models/target_type.dart';
|
||||||
|
import 'package:bully/services/score_calculator_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final service = ScoreCalculatorService();
|
||||||
|
|
||||||
|
group('calculateConcentricScore', () {
|
||||||
|
test('un tir au centre exact vaut 10', () {
|
||||||
|
final score = service.calculateConcentricScore(
|
||||||
|
shotX: 0.5,
|
||||||
|
shotY: 0.5,
|
||||||
|
targetCenterX: 0.5,
|
||||||
|
targetCenterY: 0.5,
|
||||||
|
targetRadius: 0.4,
|
||||||
|
);
|
||||||
|
expect(score, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un tir hors de la cible vaut 0', () {
|
||||||
|
final score = service.calculateConcentricScore(
|
||||||
|
shotX: 0.95,
|
||||||
|
shotY: 0.5,
|
||||||
|
targetCenterX: 0.5,
|
||||||
|
targetCenterY: 0.5,
|
||||||
|
targetRadius: 0.4,
|
||||||
|
);
|
||||||
|
expect(score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un tir à mi-rayon tombe dans la zone attendue', () {
|
||||||
|
// Distance normalisée ≈ 0.475 → 5e anneau (zone 0.5) → score 6.
|
||||||
|
final score = service.calculateConcentricScore(
|
||||||
|
shotX: 0.5,
|
||||||
|
shotY: 0.69,
|
||||||
|
targetCenterX: 0.5,
|
||||||
|
targetCenterY: 0.5,
|
||||||
|
targetRadius: 0.4,
|
||||||
|
);
|
||||||
|
expect(score, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le ratio d\'image est pris en compte (image paysage 2:1)', () {
|
||||||
|
// dx = 0.15 mais scaled ×2 → distance normalisée 0.75 → score 3.
|
||||||
|
final score = service.calculateConcentricScore(
|
||||||
|
shotX: 0.65,
|
||||||
|
shotY: 0.5,
|
||||||
|
targetCenterX: 0.5,
|
||||||
|
targetCenterY: 0.5,
|
||||||
|
targetRadius: 0.4,
|
||||||
|
imageAspectRatio: 2.0,
|
||||||
|
);
|
||||||
|
expect(score, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les rayons d\'anneaux personnalisés sont respectés', () {
|
||||||
|
// Distance normalisée 0.3 avec anneaux [0.2, 0.5, 1.0] → 2e anneau → 9.
|
||||||
|
final score = service.calculateConcentricScore(
|
||||||
|
shotX: 0.5,
|
||||||
|
shotY: 0.62,
|
||||||
|
targetCenterX: 0.5,
|
||||||
|
targetCenterY: 0.5,
|
||||||
|
targetRadius: 0.4,
|
||||||
|
ringCount: 3,
|
||||||
|
ringRadii: [0.2, 0.5, 1.0],
|
||||||
|
);
|
||||||
|
expect(score, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('calculateScores', () {
|
||||||
|
test('agrège le total, le max possible et le pourcentage', () {
|
||||||
|
final shots = [
|
||||||
|
Shot(id: 'a', x: 0.5, y: 0.5, score: 0, analysisId: ''), // centre → 10
|
||||||
|
Shot(id: 'b', x: 0.95, y: 0.5, score: 0, analysisId: ''), // dehors → 0
|
||||||
|
];
|
||||||
|
|
||||||
|
final result = service.calculateScores(
|
||||||
|
shots: shots,
|
||||||
|
targetType: TargetType.concentric,
|
||||||
|
targetCenterX: 0.5,
|
||||||
|
targetCenterY: 0.5,
|
||||||
|
targetRadius: 0.4,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.totalScore, 10);
|
||||||
|
expect(result.maxPossibleScore, 20);
|
||||||
|
expect(result.percentage, closeTo(50.0, 0.001));
|
||||||
|
expect(result.shotCount, 2);
|
||||||
|
expect(result.scoreDistribution[10], 1);
|
||||||
|
expect(result.scoreDistribution[0], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une liste vide donne un résultat neutre', () {
|
||||||
|
final result = service.calculateScores(
|
||||||
|
shots: [],
|
||||||
|
targetType: TargetType.concentric,
|
||||||
|
targetCenterX: 0.5,
|
||||||
|
targetCenterY: 0.5,
|
||||||
|
targetRadius: 0.4,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.totalScore, 0);
|
||||||
|
expect(result.maxPossibleScore, 0);
|
||||||
|
expect(result.percentage, 0.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,20 +1,32 @@
|
|||||||
// This is a basic Flutter widget test.
|
// Test de fumée sur un widget pur (sans providers ni base de données).
|
||||||
//
|
//
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
// Le montage complet de BullyApp exige les providers globaux et une base
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
// SQLite initialisée ; pour un test widget rapide et stable, on valide ici
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
// le rendu d'un composant autonome.
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:bully/app.dart';
|
import 'package:bully/features/home/widgets/stats_card.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('App loads correctly', (WidgetTester tester) async {
|
testWidgets('StatsCard affiche le titre, la valeur et l\'icône',
|
||||||
// Build our app and trigger a frame.
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const BullyApp());
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: StatsCard(
|
||||||
|
icon: Icons.gps_fixed,
|
||||||
|
title: 'Tirs',
|
||||||
|
value: '42',
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Verify that the app title is displayed.
|
expect(find.text('Tirs'), findsOneWidget);
|
||||||
expect(find.text('Bully'), findsOneWidget);
|
expect(find.text('42'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.gps_fixed), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
print(cv.approxPolyDP);
|
|
||||||
print(cv.arcLength);
|
|
||||||
print(cv.contourArea);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
print(cv.findHomography);
|
|
||||||
}
|
|
||||||