Compare commits
82 Commits
backend
...
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 | ||
|
|
f0b15941cf | ||
|
|
9f9f9beb84 | ||
|
|
c49c8b8c73 | ||
|
|
61b5fcfe22 | ||
|
|
f65c5e6a3f | ||
|
|
d487c98e3d | ||
|
|
9cb37fc303 | ||
|
|
3ae9ceebc1 | ||
|
|
251d4bb599 | ||
|
|
ccc6eb609a | ||
|
|
9e0427f750 | ||
|
|
a5d0251545 | ||
|
|
730629a031 | ||
|
|
4d2ca2c94f | ||
|
|
ab45786d0c | ||
|
|
95e62abe47 | ||
|
|
04c1204116 | ||
|
|
78c9fc96c9 | ||
|
|
e570bd4296 | ||
|
|
02c0d449e4 | ||
|
|
eabe7511bd | ||
|
|
620e12a1fd | ||
|
|
622f2936de | ||
|
|
814f778e6c | ||
|
|
2f493ef622 | ||
|
|
33e1522df0 | ||
|
|
653264c6a1 | ||
|
|
750732eafe | ||
|
|
f40bfc0ba7 | ||
|
|
b721583e98 | ||
|
|
18c01c3767 | ||
|
|
0af6a0d8e2 | ||
|
|
fbd631c8e4 | ||
|
|
3088c5ee91 | ||
|
|
fe6d52902b | ||
|
|
48b8313be8 | ||
|
|
a9ff826246 | ||
|
|
f149fe092b | ||
|
|
774dbfcf40 | ||
|
|
5dd58da51c | ||
|
|
8910a0a87e | ||
|
|
0895b7d5bc | ||
|
|
8946955f76 | ||
|
|
be62669d56 | ||
|
|
db6dd0cee5 | ||
|
|
d98f54cf93 | ||
|
|
55f6210c97 | ||
|
|
8d9b35104a | ||
|
|
5cc67228e4 |
@@ -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:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -11,6 +11,7 @@
|
|||||||
.svn/
|
.svn/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
@@ -43,3 +44,15 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Backendia
|
||||||
|
backendia/node_modules/
|
||||||
|
backendia/dashboard/node_modules/
|
||||||
|
backendia/dashboard/.next/
|
||||||
|
backendia/dashboard/out/
|
||||||
|
backendia/exports/*.zip
|
||||||
|
backendia/uploads/images/*
|
||||||
|
!backendia/uploads/images/.gitkeep
|
||||||
|
backendia/uploads/data/*
|
||||||
|
!backendia/uploads/data/.gitkeep
|
||||||
|
!backendia/uploads/data/database.sqlite
|
||||||
|
|||||||
19
CLAUDE.md
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)
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
|
||||||
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- Pour Android 12 et inférieur -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<!-- Pour Android 13 et supérieur -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<application
|
<application
|
||||||
android:label="bully"
|
android:label="bully"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
4
backendia/.gitignore
vendored
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
|
||||||
|
|
||||||
|
|||||||
6
backendia/dashboard/next-env.d.ts
vendored
Normal file
6
backendia/dashboard/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
0
backendia/uploads/data/.gitkeep
Normal file
0
backendia/uploads/data/.gitkeep
Normal file
Binary file not shown.
0
backendia/uploads/images/.gitkeep
Normal file
0
backendia/uploads/images/.gitkeep
Normal file
@@ -2,8 +2,13 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<dict>
|
||||||
<string>This app needs camera access to scan documents</string>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'accéder à vos photos pour analyser vos cibles enregistrées.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'utiliser l'appareil photo pour scanner vos cibles en temps réel.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'accéder au micro (requis par certains services de caméra même si non utilisé).</string>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
|||||||
32
lib/app.dart
32
lib/app.dart
@@ -1,19 +1,35 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'core/theme/theme_provider.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'main_navigation_holder.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
class BullyApp extends StatelessWidget {
|
class BullyApp extends StatelessWidget {
|
||||||
const BullyApp({super.key});
|
const BullyApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return Consumer<ThemeProvider>(
|
||||||
title: 'Bully - Analyse de Cibles',
|
builder: (context, themeProvider, child) {
|
||||||
debugShowCheckedModeBanner: false,
|
return MaterialApp(
|
||||||
theme: AppTheme.lightTheme,
|
title: 'Bully - Analyse de Cibles',
|
||||||
darkTheme: AppTheme.darkTheme,
|
debugShowCheckedModeBanner: false,
|
||||||
themeMode: ThemeMode.system,
|
theme: AppTheme.lightTheme,
|
||||||
home: const HomeScreen(),
|
darkTheme: AppTheme.darkTheme,
|
||||||
|
themeMode: themeProvider.themeMode,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [
|
||||||
|
Locale('fr', 'FR'), // Français
|
||||||
|
],
|
||||||
|
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
||||||
|
home: const MainNavigationHolder(),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ class AppConstants {
|
|||||||
|
|
||||||
// Database
|
// Database
|
||||||
static const String databaseName = 'bully_targets.db';
|
static const String databaseName = 'bully_targets.db';
|
||||||
static const int databaseVersion = 1;
|
static const int databaseVersion = 6;
|
||||||
|
|
||||||
// Tables
|
// Tables
|
||||||
static const String sessionsTable = 'sessions';
|
static const String sessionsTable = 'sessions';
|
||||||
|
static const String targetAnalysesTable = 'target_analyses';
|
||||||
static const String shotsTable = 'shots';
|
static const String shotsTable = 'shots';
|
||||||
|
static const String weaponsTable = 'weapons';
|
||||||
|
static const String maintenanceTable = 'weapon_maintenance';
|
||||||
|
|
||||||
// Image processing
|
// Image processing
|
||||||
static const double minImpactRadius = 2.0;
|
static const double minImpactRadius = 2.0;
|
||||||
|
|||||||
44
lib/core/theme/theme_provider.dart
Normal file
44
lib/core/theme/theme_provider.dart
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class ThemeProvider with ChangeNotifier {
|
||||||
|
static const String _themeModeKey = 'user_theme_mode';
|
||||||
|
|
||||||
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
|
||||||
|
ThemeMode get themeMode => _themeMode;
|
||||||
|
|
||||||
|
ThemeProvider() {
|
||||||
|
loadThemeMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadThemeMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final modeIndex = prefs.getInt(_themeModeKey);
|
||||||
|
if (modeIndex != null) {
|
||||||
|
_themeMode = ThemeMode.values[modeIndex];
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setThemeMode(ThemeMode mode) async {
|
||||||
|
if (_themeMode == mode) return;
|
||||||
|
|
||||||
|
_themeMode = mode;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_themeModeKey, mode.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get themeModeName {
|
||||||
|
switch (_themeMode) {
|
||||||
|
case ThemeMode.system:
|
||||||
|
return 'Automatique';
|
||||||
|
case ThemeMode.light:
|
||||||
|
return 'Clair';
|
||||||
|
case ThemeMode.dark:
|
||||||
|
return 'Sombre';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
lib/core/widgets/metric_info_button.dart
Normal file
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,12 +1,21 @@
|
|||||||
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';
|
||||||
|
import '../models/target_analysis.dart';
|
||||||
import '../models/shot.dart';
|
import '../models/shot.dart';
|
||||||
|
import '../models/weapon.dart';
|
||||||
|
import '../models/maintenance.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
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();
|
||||||
|
|
||||||
@@ -16,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!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +47,19 @@ class DatabaseHelper {
|
|||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE ${AppConstants.sessionsTable} (
|
CREATE TABLE ${AppConstants.sessionsTable} (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon TEXT NOT NULL,
|
||||||
|
weapon_id TEXT,
|
||||||
|
max_shots_per_target INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
distance INTEGER DEFAULT 25
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.targetAnalysesTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
target_type TEXT NOT NULL,
|
target_type TEXT NOT NULL,
|
||||||
image_path TEXT NOT NULL,
|
image_path TEXT NOT NULL,
|
||||||
total_score INTEGER NOT NULL,
|
total_score INTEGER NOT NULL,
|
||||||
@@ -46,7 +70,8 @@ class DatabaseHelper {
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
target_center_x REAL,
|
target_center_x REAL,
|
||||||
target_center_y REAL,
|
target_center_y REAL,
|
||||||
target_radius REAL
|
target_radius REAL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
@@ -56,48 +81,262 @@ class DatabaseHelper {
|
|||||||
x REAL NOT NULL,
|
x REAL NOT NULL,
|
||||||
y REAL NOT NULL,
|
y REAL NOT NULL,
|
||||||
score INTEGER NOT NULL,
|
score INTEGER NOT NULL,
|
||||||
session_id TEXT NOT NULL,
|
analysis_id TEXT NOT NULL,
|
||||||
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id)
|
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.weaponsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
caliber TEXT NOT NULL,
|
||||||
|
magazine_count INTEGER NOT NULL,
|
||||||
|
magazine_capacity INTEGER NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
optic TEXT,
|
||||||
|
silencer TEXT,
|
||||||
|
trigger TEXT,
|
||||||
|
custom_name TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.maintenanceTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
rounds_since_last INTEGER,
|
||||||
|
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
// Handle future database migrations here
|
if (oldVersion < 2) {
|
||||||
|
// Migration from single-target sessions to multi-target sessions
|
||||||
|
|
||||||
|
// 1. Rename existing sessions table to a temporary name
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} RENAME TO sessions_old');
|
||||||
|
|
||||||
|
// 2. Create the new sessions table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.sessionsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon TEXT NOT NULL,
|
||||||
|
max_shots_per_target INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
notes TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 3. Create the new target_analyses table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.targetAnalysesTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
total_score INTEGER NOT NULL,
|
||||||
|
grouping_diameter REAL,
|
||||||
|
grouping_center_x REAL,
|
||||||
|
grouping_center_y REAL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
target_center_x REAL,
|
||||||
|
target_center_y REAL,
|
||||||
|
target_radius REAL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 4. Update shots table (rename session_id to analysis_id)
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.shotsTable} RENAME TO shots_old');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.shotsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
x REAL NOT NULL,
|
||||||
|
y REAL NOT NULL,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
analysis_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 5. Migrate data
|
||||||
|
// For each old session, create one session and one target_analysis
|
||||||
|
final oldSessions = await db.query('sessions_old');
|
||||||
|
for (final old in oldSessions) {
|
||||||
|
final sessionId = old['id'] as String;
|
||||||
|
final analysisId = 'analysis_$sessionId'; // Create a unique ID for the analysis
|
||||||
|
|
||||||
|
// Insert into new sessions
|
||||||
|
await db.insert(AppConstants.sessionsTable, {
|
||||||
|
'id': sessionId,
|
||||||
|
'weapon': 'Inconnue',
|
||||||
|
'max_shots_per_target': 5,
|
||||||
|
'created_at': old['created_at'],
|
||||||
|
'notes': old['notes'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert into target_analyses
|
||||||
|
await db.insert(AppConstants.targetAnalysesTable, {
|
||||||
|
'id': analysisId,
|
||||||
|
'session_id': sessionId,
|
||||||
|
'target_type': old['target_type'],
|
||||||
|
'image_path': old['image_path'],
|
||||||
|
'total_score': old['total_score'],
|
||||||
|
'grouping_diameter': old['grouping_diameter'],
|
||||||
|
'grouping_center_x': old['grouping_center_x'],
|
||||||
|
'grouping_center_y': old['grouping_center_y'],
|
||||||
|
'created_at': old['created_at'],
|
||||||
|
'notes': null,
|
||||||
|
'target_center_x': old['target_center_x'],
|
||||||
|
'target_center_y': old['target_center_y'],
|
||||||
|
'target_radius': old['target_radius'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate shots from shots_old to shots
|
||||||
|
final oldShots = await db.query('shots_old', where: 'session_id = ?', whereArgs: [sessionId]);
|
||||||
|
for (final shot in oldShots) {
|
||||||
|
await db.insert(AppConstants.shotsTable, {
|
||||||
|
'id': shot['id'],
|
||||||
|
'x': shot['x'],
|
||||||
|
'y': shot['y'],
|
||||||
|
'score': shot['score'],
|
||||||
|
'analysis_id': analysisId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Cleanup
|
||||||
|
await db.execute('DROP TABLE sessions_old');
|
||||||
|
await db.execute('DROP TABLE shots_old');
|
||||||
|
|
||||||
|
// 7. Re-create indexes
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
// Add weapons table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.weaponsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
caliber TEXT NOT NULL,
|
||||||
|
magazine_count INTEGER NOT NULL,
|
||||||
|
magazine_capacity INTEGER NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// Add weapon_id to sessions table
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN weapon_id TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
// Add maintenance table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.maintenanceTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
rounds_since_last INTEGER,
|
||||||
|
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 5) {
|
||||||
|
// Add new weapon options columns
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN optic TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN silencer TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN "trigger" TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN custom_name TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
// Add distance column to sessions
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN distance INTEGER DEFAULT 25');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
Future<int> insertSession(Session session) async {
|
Future<int> insertSession(Session session) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return await db.transaction((txn) async {
|
return await db.transaction((txn) async {
|
||||||
|
// INTERCEPTION ET FIX DE LA DATE ICI :
|
||||||
|
// On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet
|
||||||
|
final Map<String, dynamic> sessionMap = Map.from(session.toMap());
|
||||||
|
sessionMap['created_at'] = session.createdAt.toIso8601String();
|
||||||
|
|
||||||
await txn.insert(
|
await txn.insert(
|
||||||
AppConstants.sessionsTable,
|
AppConstants.sessionsTable,
|
||||||
session.toMap(),
|
sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct !
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (final shot in session.shots) {
|
for (final analysis in session.analyses) {
|
||||||
// Ensure shot has correct session_id
|
await insertTargetAnalysisTx(txn, analysis, session.id);
|
||||||
final shotWithSessionId = shot.copyWith(sessionId: session.id);
|
|
||||||
await txn.insert(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
shotWithSessionId.toMap(),
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> insertTargetAnalysis(TargetAnalysis analysis, String sessionId) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await insertTargetAnalysisTx(txn, analysis, sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertTargetAnalysisTx(Transaction txn, TargetAnalysis analysis, String sessionId) async {
|
||||||
|
final analysisWithSessionId = analysis.copyWith(sessionId: sessionId);
|
||||||
|
await txn.insert(
|
||||||
|
AppConstants.targetAnalysesTable,
|
||||||
|
analysisWithSessionId.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final shot in analysis.shots) {
|
||||||
|
final shotWithAnalysisId = shot.copyWith(analysisId: analysis.id);
|
||||||
|
await txn.insert(
|
||||||
|
AppConstants.shotsTable,
|
||||||
|
shotWithAnalysisId.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Session?> getSession(String id) async {
|
Future<Session?> getSession(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final sessionMaps = await db.query(
|
final sessionMaps = await db.query(
|
||||||
@@ -108,104 +347,104 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
if (sessionMaps.isEmpty) return null;
|
if (sessionMaps.isEmpty) return null;
|
||||||
|
|
||||||
final shotMaps = await db.query(
|
final analysisMaps = await db.query(
|
||||||
AppConstants.shotsTable,
|
AppConstants.targetAnalysesTable,
|
||||||
where: 'session_id = ?',
|
where: 'session_id = ?',
|
||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
|
|
||||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
final analyses = <TargetAnalysis>[];
|
||||||
return Session.fromMap(sessionMaps.first, shots);
|
for (final analysisMap in analysisMaps) {
|
||||||
|
final analysisId = analysisMap['id'] as String;
|
||||||
|
final shotMaps = await db.query(
|
||||||
|
AppConstants.shotsTable,
|
||||||
|
where: 'analysis_id = ?',
|
||||||
|
whereArgs: [analysisId],
|
||||||
|
);
|
||||||
|
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
||||||
|
analyses.add(TargetAnalysis.fromMap(analysisMap, shots));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Session.fromMap(sessionMaps.first, analyses);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Session>> getAllSessions({
|
Future<List<Session>> getAllSessions({
|
||||||
String? targetType,
|
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
||||||
String? whereClause;
|
|
||||||
List<dynamic>? whereArgs;
|
|
||||||
|
|
||||||
if (targetType != null) {
|
|
||||||
whereClause = 'target_type = ?';
|
|
||||||
whereArgs = [targetType];
|
|
||||||
}
|
|
||||||
|
|
||||||
final sessionMaps = await db.query(
|
final sessionMaps = await db.query(
|
||||||
AppConstants.sessionsTable,
|
AppConstants.sessionsTable,
|
||||||
where: whereClause,
|
|
||||||
whereArgs: whereArgs,
|
|
||||||
orderBy: 'created_at DESC',
|
orderBy: 'created_at DESC',
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
);
|
);
|
||||||
|
if (sessionMaps.isEmpty) return [];
|
||||||
|
|
||||||
// First, check if there are orphaned shots (with empty session_id)
|
// 3 requêtes groupées au lieu d'une cascade par session puis par analyse
|
||||||
// and only one session - if so, assign them to that session
|
// (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
|
||||||
if (sessionMaps.length == 1) {
|
// ce qui ralentissait l'accueil/historique/stats au fil des mois.
|
||||||
final orphanedShots = await db.query(
|
final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
|
||||||
AppConstants.shotsTable,
|
final analysisMaps = await _queryIn(
|
||||||
where: 'session_id = ?',
|
db,
|
||||||
whereArgs: [''],
|
AppConstants.targetAnalysesTable,
|
||||||
);
|
'session_id',
|
||||||
if (orphanedShots.isNotEmpty) {
|
sessionIds,
|
||||||
await db.update(
|
);
|
||||||
AppConstants.shotsTable,
|
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
|
||||||
{'session_id': sessionMaps.first['id']},
|
final shotMaps = await _queryIn(
|
||||||
where: 'session_id = ?',
|
db,
|
||||||
whereArgs: [''],
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
final sessions = <Session>[];
|
final analysesBySession = <String, List<TargetAnalysis>>{};
|
||||||
for (final sessionMap in sessionMaps) {
|
for (final map in analysisMaps) {
|
||||||
final sessionId = sessionMap['id'] as String;
|
final analysis = TargetAnalysis.fromMap(
|
||||||
|
map,
|
||||||
final shotMaps = await db.query(
|
shotsByAnalysis[map['id'] as String] ?? [],
|
||||||
AppConstants.shotsTable,
|
|
||||||
where: 'session_id = ?',
|
|
||||||
whereArgs: [sessionId],
|
|
||||||
);
|
);
|
||||||
|
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
|
||||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
|
||||||
sessions.add(Session.fromMap(sessionMap, shots));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions;
|
return sessionMaps
|
||||||
|
.map((m) =>
|
||||||
|
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> updateSession(Session session) async {
|
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
|
||||||
final db = await database;
|
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
|
||||||
return await db.transaction((txn) async {
|
Future<List<Map<String, Object?>>> _queryIn(
|
||||||
await txn.update(
|
Database db,
|
||||||
AppConstants.sessionsTable,
|
String table,
|
||||||
session.toMap(),
|
String column,
|
||||||
where: 'id = ?',
|
List<String> values,
|
||||||
whereArgs: [session.id],
|
) 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(',');
|
||||||
// Delete existing shots and insert new ones
|
results.addAll(await db.query(
|
||||||
await txn.delete(
|
table,
|
||||||
AppConstants.shotsTable,
|
where: '$column IN ($placeholders)',
|
||||||
where: 'session_id = ?',
|
whereArgs: chunk,
|
||||||
whereArgs: [session.id],
|
));
|
||||||
);
|
}
|
||||||
|
return results;
|
||||||
for (final shot in session.shots) {
|
|
||||||
// Ensure shot has correct session_id
|
|
||||||
final shotWithSessionId = shot.copyWith(sessionId: session.id);
|
|
||||||
await txn.insert(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
shotWithSessionId.toMap(),
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> deleteSession(String id) async {
|
Future<int> deleteSession(String id) async {
|
||||||
@@ -229,12 +468,12 @@ class DatabaseHelper {
|
|||||||
) ?? 0;
|
) ?? 0;
|
||||||
|
|
||||||
final avgScore = (await db.rawQuery(
|
final avgScore = (await db.rawQuery(
|
||||||
'SELECT AVG(total_score) as avg FROM ${AppConstants.sessionsTable}',
|
'SELECT AVG(total_score) as avg FROM ${AppConstants.targetAnalysesTable}',
|
||||||
)).first['avg'] as double? ?? 0.0;
|
)).first['avg'] as double? ?? 0.0;
|
||||||
|
|
||||||
final bestScore = Sqflite.firstIntValue(
|
final bestScore = Sqflite.firstIntValue(
|
||||||
await db.rawQuery(
|
await db.rawQuery(
|
||||||
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}',
|
'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}',
|
||||||
),
|
),
|
||||||
) ?? 0;
|
) ?? 0;
|
||||||
|
|
||||||
@@ -246,9 +485,135 @@ class DatabaseHelper {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weapon operations
|
||||||
|
Future<int> insertWeapon(Weapon weapon) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.insert(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
weapon.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Weapon>> getAllWeapons() async {
|
||||||
|
final db = await database;
|
||||||
|
final List<Map<String, dynamic>> maps = await db.query(AppConstants.weaponsTable, orderBy: 'name ASC');
|
||||||
|
return List.generate(maps.length, (i) => Weapon.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Weapon?> getWeapon(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
final List<Map<String, dynamic>> maps = await db.query(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
if (maps.isEmpty) return null;
|
||||||
|
return Weapon.fromMap(maps.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> updateWeapon(Weapon weapon) async {
|
||||||
|
final db = await database;
|
||||||
|
|
||||||
|
// Start a transaction to ensure both updates succeed
|
||||||
|
return await db.transaction((txn) async {
|
||||||
|
// 1. Update weapon in armory
|
||||||
|
final count = await txn.update(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
weapon.toMap(),
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [weapon.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Propagate name change to all sessions linked to this weapon
|
||||||
|
await txn.update(
|
||||||
|
AppConstants.sessionsTable,
|
||||||
|
{'weapon': weapon.displayName},
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
whereArgs: [weapon.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> deleteWeapon(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
|
||||||
|
return await db.transaction((txn) async {
|
||||||
|
// 1. Unlink sessions from this weapon but KEEP the name (gravé à jamais)
|
||||||
|
await txn.update(
|
||||||
|
AppConstants.sessionsTable,
|
||||||
|
{'weapon_id': null},
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Delete the weapon record
|
||||||
|
return await txn.delete(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weapon statistics & Maintenance
|
||||||
|
Future<int> getRoundsFiredForWeapon(String weaponId) async {
|
||||||
|
final db = await database;
|
||||||
|
final result = await db.rawQuery('''
|
||||||
|
SELECT COUNT(s.id) as count
|
||||||
|
FROM ${AppConstants.shotsTable} s
|
||||||
|
JOIN ${AppConstants.targetAnalysesTable} ta ON s.analysis_id = ta.id
|
||||||
|
JOIN ${AppConstants.sessionsTable} sess ON ta.session_id = sess.id
|
||||||
|
WHERE sess.weapon_id = ?
|
||||||
|
''', [weaponId]);
|
||||||
|
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 {
|
||||||
|
final db = await database;
|
||||||
|
return await db.insert(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
entry.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
|
||||||
|
final db = await database;
|
||||||
|
final maps = await db.query(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
orderBy: 'date DESC',
|
||||||
|
whereArgs: [weaponId],
|
||||||
|
);
|
||||||
|
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> deleteMaintenance(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.delete(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
_database = null;
|
_database = null;
|
||||||
|
_initFuture = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
57
lib/data/models/maintenance.dart
Normal file
57
lib/data/models/maintenance.dart
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
enum MaintenanceType {
|
||||||
|
cleaning('Nettoyage'),
|
||||||
|
repair('Réparation'),
|
||||||
|
inspection('Inspection'),
|
||||||
|
upgrade('Amélioration'),
|
||||||
|
other('Autre');
|
||||||
|
|
||||||
|
final String displayName;
|
||||||
|
const MaintenanceType(this.displayName);
|
||||||
|
|
||||||
|
static MaintenanceType fromString(String value) {
|
||||||
|
return MaintenanceType.values.firstWhere(
|
||||||
|
(type) => type.name == value,
|
||||||
|
orElse: () => MaintenanceType.other,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaintenanceEntry {
|
||||||
|
final String id;
|
||||||
|
final String weaponId;
|
||||||
|
final MaintenanceType type;
|
||||||
|
final String description;
|
||||||
|
final DateTime date;
|
||||||
|
final int? roundsSinceLastMaintenance;
|
||||||
|
|
||||||
|
MaintenanceEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.weaponId,
|
||||||
|
required this.type,
|
||||||
|
required this.description,
|
||||||
|
required this.date,
|
||||||
|
this.roundsSinceLastMaintenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'weapon_id': weaponId,
|
||||||
|
'type': type.name,
|
||||||
|
'description': description,
|
||||||
|
'date': date.toIso8601String(),
|
||||||
|
'rounds_since_last': roundsSinceLastMaintenance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MaintenanceEntry.fromMap(Map<String, dynamic> map) {
|
||||||
|
return MaintenanceEntry(
|
||||||
|
id: map['id'] as String,
|
||||||
|
weaponId: map['weapon_id'] as String,
|
||||||
|
type: MaintenanceType.fromString(map['type'] as String),
|
||||||
|
description: map['description'] as String,
|
||||||
|
date: DateTime.parse(map['date'] as String),
|
||||||
|
roundsSinceLastMaintenance: map['rounds_since_last'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,112 +1,83 @@
|
|||||||
import 'shot.dart';
|
import 'target_analysis.dart';
|
||||||
import 'target_type.dart';
|
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
final String id;
|
final String id;
|
||||||
final TargetType targetType;
|
final String weapon;
|
||||||
final String imagePath;
|
final String? weaponId;
|
||||||
final List<Shot> shots;
|
final int maxShotsPerTarget;
|
||||||
final int totalScore;
|
final List<TargetAnalysis> analyses;
|
||||||
final double? groupingDiameter;
|
|
||||||
final double? groupingCenterX;
|
|
||||||
final double? groupingCenterY;
|
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? notes;
|
final String? notes;
|
||||||
|
final int distance; // Shooting distance in meters
|
||||||
// Target detection data
|
|
||||||
final double? targetCenterX;
|
|
||||||
final double? targetCenterY;
|
|
||||||
final double? targetRadius;
|
|
||||||
|
|
||||||
Session({
|
Session({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.targetType,
|
required this.weapon,
|
||||||
required this.imagePath,
|
this.weaponId,
|
||||||
required this.shots,
|
required this.maxShotsPerTarget,
|
||||||
required this.totalScore,
|
required this.analyses,
|
||||||
this.groupingDiameter,
|
|
||||||
this.groupingCenterX,
|
|
||||||
this.groupingCenterY,
|
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.notes,
|
this.notes,
|
||||||
this.targetCenterX,
|
this.distance = 25, // Default distance
|
||||||
this.targetCenterY,
|
|
||||||
this.targetRadius,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
int get shotCount => shots.length;
|
int get targetCount => analyses.length;
|
||||||
|
|
||||||
|
int get totalShots => analyses.fold(0, (sum, a) => sum + a.shotCount);
|
||||||
|
|
||||||
|
int get totalScore => analyses.fold(0, (sum, a) => sum + a.totalScore);
|
||||||
|
|
||||||
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
|
double get averageScore => analyses.isEmpty ? 0.0 : totalScore / analyses.length;
|
||||||
|
|
||||||
Session copyWith({
|
Session copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
TargetType? targetType,
|
String? weapon,
|
||||||
String? imagePath,
|
String? weaponId,
|
||||||
List<Shot>? shots,
|
int? maxShotsPerTarget,
|
||||||
int? totalScore,
|
List<TargetAnalysis>? analyses,
|
||||||
double? groupingDiameter,
|
|
||||||
double? groupingCenterX,
|
|
||||||
double? groupingCenterY,
|
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? notes,
|
String? notes,
|
||||||
double? targetCenterX,
|
int? distance,
|
||||||
double? targetCenterY,
|
|
||||||
double? targetRadius,
|
|
||||||
}) {
|
}) {
|
||||||
return Session(
|
return Session(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
targetType: targetType ?? this.targetType,
|
weapon: weapon ?? this.weapon,
|
||||||
imagePath: imagePath ?? this.imagePath,
|
weaponId: weaponId ?? this.weaponId,
|
||||||
shots: shots ?? this.shots,
|
maxShotsPerTarget: maxShotsPerTarget ?? this.maxShotsPerTarget,
|
||||||
totalScore: totalScore ?? this.totalScore,
|
analyses: analyses ?? this.analyses,
|
||||||
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
|
|
||||||
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
|
|
||||||
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
|
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
targetCenterX: targetCenterX ?? this.targetCenterX,
|
distance: distance ?? this.distance,
|
||||||
targetCenterY: targetCenterY ?? this.targetCenterY,
|
|
||||||
targetRadius: targetRadius ?? this.targetRadius,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
'target_type': targetType.name,
|
'weapon': weapon,
|
||||||
'image_path': imagePath,
|
'weapon_id': weaponId,
|
||||||
'total_score': totalScore,
|
'max_shots_per_target': maxShotsPerTarget,
|
||||||
'grouping_diameter': groupingDiameter,
|
|
||||||
'grouping_center_x': groupingCenterX,
|
|
||||||
'grouping_center_y': groupingCenterY,
|
|
||||||
'created_at': createdAt.toIso8601String(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
'notes': notes,
|
'notes': notes,
|
||||||
'target_center_x': targetCenterX,
|
'distance': distance,
|
||||||
'target_center_y': targetCenterY,
|
|
||||||
'target_radius': targetRadius,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
factory Session.fromMap(Map<String, dynamic> map, List<Shot> shots) {
|
factory Session.fromMap(Map<String, dynamic> map, List<TargetAnalysis> analyses) {
|
||||||
return Session(
|
return Session(
|
||||||
id: map['id'] as String,
|
id: map['id'] as String,
|
||||||
targetType: TargetType.fromString(map['target_type'] as String),
|
weapon: map['weapon'] as String? ?? 'Inconnue',
|
||||||
imagePath: map['image_path'] as String,
|
weaponId: map['weapon_id'] as String?,
|
||||||
shots: shots,
|
maxShotsPerTarget: map['max_shots_per_target'] as int? ?? 5,
|
||||||
totalScore: map['total_score'] as int,
|
analyses: analyses,
|
||||||
groupingDiameter: map['grouping_diameter'] as double?,
|
|
||||||
groupingCenterX: map['grouping_center_x'] as double?,
|
|
||||||
groupingCenterY: map['grouping_center_y'] as double?,
|
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: DateTime.parse(map['created_at'] as String),
|
||||||
notes: map['notes'] as String?,
|
notes: map['notes'] as String?,
|
||||||
targetCenterX: map['target_center_x'] as double?,
|
distance: map['distance'] as int? ?? 25,
|
||||||
targetCenterY: map['target_center_y'] as double?,
|
|
||||||
targetRadius: map['target_radius'] as double?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'Session(id: $id, targetType: $targetType, shots: ${shots.length}, totalScore: $totalScore, createdAt: $createdAt)';
|
return 'Session(id: $id, weapon: $weapon, distance: ${distance}m, targets: ${analyses.length}, createdAt: $createdAt)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ class Shot {
|
|||||||
final double x; // Relative position (0.0 - 1.0)
|
final double x; // Relative position (0.0 - 1.0)
|
||||||
final double y; // Relative position (0.0 - 1.0)
|
final double y; // Relative position (0.0 - 1.0)
|
||||||
final int score;
|
final int score;
|
||||||
final String sessionId;
|
final String analysisId;
|
||||||
|
|
||||||
Shot({
|
Shot({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.x,
|
required this.x,
|
||||||
required this.y,
|
required this.y,
|
||||||
required this.score,
|
required this.score,
|
||||||
required this.sessionId,
|
required this.analysisId,
|
||||||
});
|
});
|
||||||
|
|
||||||
Shot copyWith({
|
Shot copyWith({
|
||||||
@@ -18,14 +18,14 @@ class Shot {
|
|||||||
double? x,
|
double? x,
|
||||||
double? y,
|
double? y,
|
||||||
int? score,
|
int? score,
|
||||||
String? sessionId,
|
String? analysisId,
|
||||||
}) {
|
}) {
|
||||||
return Shot(
|
return Shot(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
x: x ?? this.x,
|
x: x ?? this.x,
|
||||||
y: y ?? this.y,
|
y: y ?? this.y,
|
||||||
score: score ?? this.score,
|
score: score ?? this.score,
|
||||||
sessionId: sessionId ?? this.sessionId,
|
analysisId: analysisId ?? this.analysisId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class Shot {
|
|||||||
'x': x,
|
'x': x,
|
||||||
'y': y,
|
'y': y,
|
||||||
'score': score,
|
'score': score,
|
||||||
'session_id': sessionId,
|
'analysis_id': analysisId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,13 +45,13 @@ class Shot {
|
|||||||
x: (map['x'] as num).toDouble(),
|
x: (map['x'] as num).toDouble(),
|
||||||
y: (map['y'] as num).toDouble(),
|
y: (map['y'] as num).toDouble(),
|
||||||
score: map['score'] as int,
|
score: map['score'] as int,
|
||||||
sessionId: map['session_id'] as String,
|
analysisId: map['analysis_id'] as String? ?? map['session_id'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'Shot(id: $id, x: $x, y: $y, score: $score, sessionId: $sessionId)';
|
return 'Shot(id: $id, x: $x, y: $y, score: $score, analysisId: $analysisId)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -62,11 +62,11 @@ class Shot {
|
|||||||
other.x == x &&
|
other.x == x &&
|
||||||
other.y == y &&
|
other.y == y &&
|
||||||
other.score == score &&
|
other.score == score &&
|
||||||
other.sessionId == sessionId;
|
other.analysisId == analysisId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode {
|
||||||
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ sessionId.hashCode;
|
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ analysisId.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
lib/data/models/target_analysis.dart
Normal file
113
lib/data/models/target_analysis.dart
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import 'shot.dart';
|
||||||
|
import 'target_type.dart';
|
||||||
|
|
||||||
|
class TargetAnalysis {
|
||||||
|
final String id;
|
||||||
|
final String sessionId;
|
||||||
|
final TargetType targetType;
|
||||||
|
final String imagePath;
|
||||||
|
final List<Shot> shots;
|
||||||
|
final int totalScore;
|
||||||
|
final double? groupingDiameter;
|
||||||
|
final double? groupingCenterX;
|
||||||
|
final double? groupingCenterY;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
// Target detection data
|
||||||
|
final double? targetCenterX;
|
||||||
|
final double? targetCenterY;
|
||||||
|
final double? targetRadius;
|
||||||
|
|
||||||
|
TargetAnalysis({
|
||||||
|
required this.id,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.targetType,
|
||||||
|
required this.imagePath,
|
||||||
|
required this.shots,
|
||||||
|
required this.totalScore,
|
||||||
|
this.groupingDiameter,
|
||||||
|
this.groupingCenterX,
|
||||||
|
this.groupingCenterY,
|
||||||
|
required this.createdAt,
|
||||||
|
this.notes,
|
||||||
|
this.targetCenterX,
|
||||||
|
this.targetCenterY,
|
||||||
|
this.targetRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
int get shotCount => shots.length;
|
||||||
|
|
||||||
|
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
|
||||||
|
|
||||||
|
TargetAnalysis copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sessionId,
|
||||||
|
TargetType? targetType,
|
||||||
|
String? imagePath,
|
||||||
|
List<Shot>? shots,
|
||||||
|
int? totalScore,
|
||||||
|
double? groupingDiameter,
|
||||||
|
double? groupingCenterX,
|
||||||
|
double? groupingCenterY,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? notes,
|
||||||
|
double? targetCenterX,
|
||||||
|
double? targetCenterY,
|
||||||
|
double? targetRadius,
|
||||||
|
}) {
|
||||||
|
return TargetAnalysis(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
targetType: targetType ?? this.targetType,
|
||||||
|
imagePath: imagePath ?? this.imagePath,
|
||||||
|
shots: shots ?? this.shots,
|
||||||
|
totalScore: totalScore ?? this.totalScore,
|
||||||
|
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
|
||||||
|
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
|
||||||
|
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
targetCenterX: targetCenterX ?? this.targetCenterX,
|
||||||
|
targetCenterY: targetCenterY ?? this.targetCenterY,
|
||||||
|
targetRadius: targetRadius ?? this.targetRadius,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'session_id': sessionId,
|
||||||
|
'target_type': targetType.name,
|
||||||
|
'image_path': imagePath,
|
||||||
|
'total_score': totalScore,
|
||||||
|
'grouping_diameter': groupingDiameter,
|
||||||
|
'grouping_center_x': groupingCenterX,
|
||||||
|
'grouping_center_y': groupingCenterY,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'notes': notes,
|
||||||
|
'target_center_x': targetCenterX,
|
||||||
|
'target_center_y': targetCenterY,
|
||||||
|
'target_radius': targetRadius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory TargetAnalysis.fromMap(Map<String, dynamic> map, List<Shot> shots) {
|
||||||
|
return TargetAnalysis(
|
||||||
|
id: map['id'] as String,
|
||||||
|
sessionId: map['session_id'] as String,
|
||||||
|
targetType: TargetType.fromString(map['target_type'] as String),
|
||||||
|
imagePath: map['image_path'] as String,
|
||||||
|
shots: shots,
|
||||||
|
totalScore: map['total_score'] as int,
|
||||||
|
groupingDiameter: map['grouping_diameter'] as double?,
|
||||||
|
groupingCenterX: map['grouping_center_x'] as double?,
|
||||||
|
groupingCenterY: map['grouping_center_y'] as double?,
|
||||||
|
createdAt: DateTime.parse(map['created_at'] as String),
|
||||||
|
notes: map['notes'] as String?,
|
||||||
|
targetCenterX: map['target_center_x'] as double?,
|
||||||
|
targetCenterY: map['target_center_y'] as double?,
|
||||||
|
targetRadius: map['target_radius'] as double?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
lib/data/models/weapon.dart
Normal file
114
lib/data/models/weapon.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
enum WeaponType {
|
||||||
|
handgun('Arme de Poing'),
|
||||||
|
rifle('Arme d\'Épaule'),
|
||||||
|
shotgun('Fusil à Pompe'),
|
||||||
|
airgun('Airsoft / Airgun');
|
||||||
|
|
||||||
|
final String displayName;
|
||||||
|
const WeaponType(this.displayName);
|
||||||
|
|
||||||
|
static WeaponType fromString(String value) {
|
||||||
|
return WeaponType.values.firstWhere(
|
||||||
|
(type) => type.name == value,
|
||||||
|
orElse: () => WeaponType.handgun,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Weapon {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final WeaponType type;
|
||||||
|
final String caliber;
|
||||||
|
final int magazineCount;
|
||||||
|
final int magazineCapacity;
|
||||||
|
final String? notes;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
// New options
|
||||||
|
final String? optic;
|
||||||
|
final String? silencer;
|
||||||
|
final String? trigger;
|
||||||
|
final String? customName;
|
||||||
|
|
||||||
|
String get displayName => (customName != null && customName!.isNotEmpty) ? customName! : name;
|
||||||
|
|
||||||
|
Weapon({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
required this.caliber,
|
||||||
|
required this.magazineCount,
|
||||||
|
required this.magazineCapacity,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
this.optic,
|
||||||
|
this.silencer,
|
||||||
|
this.trigger,
|
||||||
|
this.customName,
|
||||||
|
});
|
||||||
|
|
||||||
|
Weapon copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
WeaponType? type,
|
||||||
|
String? caliber,
|
||||||
|
int? magazineCount,
|
||||||
|
int? magazineCapacity,
|
||||||
|
String? notes,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? optic,
|
||||||
|
String? silencer,
|
||||||
|
String? trigger,
|
||||||
|
String? customName,
|
||||||
|
}) {
|
||||||
|
return Weapon(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
type: type ?? this.type,
|
||||||
|
caliber: caliber ?? this.caliber,
|
||||||
|
magazineCount: magazineCount ?? this.magazineCount,
|
||||||
|
magazineCapacity: magazineCapacity ?? this.magazineCapacity,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
optic: optic ?? this.optic,
|
||||||
|
silencer: silencer ?? this.silencer,
|
||||||
|
trigger: trigger ?? this.trigger,
|
||||||
|
customName: customName ?? this.customName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'type': type.name,
|
||||||
|
'caliber': caliber,
|
||||||
|
'magazine_count': magazineCount,
|
||||||
|
'magazine_capacity': magazineCapacity,
|
||||||
|
'notes': notes,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'optic': optic,
|
||||||
|
'silencer': silencer,
|
||||||
|
'trigger': trigger,
|
||||||
|
'custom_name': customName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Weapon.fromMap(Map<String, dynamic> map) {
|
||||||
|
return Weapon(
|
||||||
|
id: map['id'] as String,
|
||||||
|
name: map['name'] as String,
|
||||||
|
type: WeaponType.fromString(map['type'] as String),
|
||||||
|
caliber: map['caliber'] as String,
|
||||||
|
magazineCount: map['magazine_count'] as int,
|
||||||
|
magazineCapacity: map['magazine_capacity'] as int,
|
||||||
|
notes: map['notes'] as String?,
|
||||||
|
createdAt: DateTime.parse(map['created_at'] as String),
|
||||||
|
optic: map['optic'] as String?,
|
||||||
|
silencer: map['silencer'] as String?,
|
||||||
|
trigger: map['trigger'] as String?,
|
||||||
|
customName: map['custom_name'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import 'package:path/path.dart' as path;
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../database/database_helper.dart';
|
import '../database/database_helper.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
|
import '../models/target_analysis.dart';
|
||||||
import '../models/shot.dart';
|
import '../models/shot.dart';
|
||||||
import '../models/target_type.dart';
|
import '../models/weapon.dart';
|
||||||
|
import '../models/maintenance.dart';
|
||||||
|
|
||||||
class SessionRepository {
|
class SessionRepository {
|
||||||
final DatabaseHelper _databaseHelper;
|
final DatabaseHelper _databaseHelper;
|
||||||
@@ -18,8 +20,38 @@ class SessionRepository {
|
|||||||
_uuid = uuid ?? const Uuid();
|
_uuid = uuid ?? const Uuid();
|
||||||
|
|
||||||
Future<Session> createSession({
|
Future<Session> createSession({
|
||||||
required TargetType targetType,
|
String? id,
|
||||||
|
required String weapon,
|
||||||
|
String? weaponId,
|
||||||
|
required int maxShotsPerTarget,
|
||||||
|
required List<TargetAnalysis> analyses,
|
||||||
|
String? notes,
|
||||||
|
int distance = 25,
|
||||||
|
DateTime? date,
|
||||||
|
}) async {
|
||||||
|
final session = Session(
|
||||||
|
id: id ?? _uuid.v4(),
|
||||||
|
weapon: weapon,
|
||||||
|
weaponId: weaponId,
|
||||||
|
maxShotsPerTarget: maxShotsPerTarget,
|
||||||
|
analyses: analyses,
|
||||||
|
createdAt: date ?? DateTime.now(),
|
||||||
|
notes: notes,
|
||||||
|
distance: distance,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _databaseHelper.insertSession(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addAnalysisToSession(String sessionId, TargetAnalysis analysis) async {
|
||||||
|
await _databaseHelper.insertTargetAnalysis(analysis, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TargetAnalysis> prepareTargetAnalysis({
|
||||||
required String imagePath,
|
required String imagePath,
|
||||||
|
required String sessionId,
|
||||||
|
required dynamic targetType, // TargetType
|
||||||
required List<Shot> shots,
|
required List<Shot> shots,
|
||||||
required int totalScore,
|
required int totalScore,
|
||||||
double? groupingDiameter,
|
double? groupingDiameter,
|
||||||
@@ -33,8 +65,9 @@ class SessionRepository {
|
|||||||
// Copy image to app documents directory
|
// Copy image to app documents directory
|
||||||
final savedImagePath = await _saveImage(imagePath);
|
final savedImagePath = await _saveImage(imagePath);
|
||||||
|
|
||||||
final session = Session(
|
return TargetAnalysis(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
|
sessionId: sessionId,
|
||||||
targetType: targetType,
|
targetType: targetType,
|
||||||
imagePath: savedImagePath,
|
imagePath: savedImagePath,
|
||||||
shots: shots,
|
shots: shots,
|
||||||
@@ -48,9 +81,6 @@ class SessionRepository {
|
|||||||
targetCenterY: targetCenterY,
|
targetCenterY: targetCenterY,
|
||||||
targetRadius: targetRadius,
|
targetRadius: targetRadius,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _databaseHelper.insertSession(session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> _saveImage(String sourcePath) async {
|
Future<String> _saveImage(String sourcePath) async {
|
||||||
@@ -75,28 +105,23 @@ class SessionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Session>> getAllSessions({
|
Future<List<Session>> getAllSessions({
|
||||||
TargetType? targetType,
|
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
}) async {
|
}) async {
|
||||||
return await _databaseHelper.getAllSessions(
|
return await _databaseHelper.getAllSessions(
|
||||||
targetType: targetType?.name,
|
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateSession(Session session) async {
|
|
||||||
await _databaseHelper.updateSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteSession(String id) async {
|
Future<void> deleteSession(String id) async {
|
||||||
final session = await getSession(id);
|
final session = await getSession(id);
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
// Delete the image file
|
for (final analysis in session.analyses) {
|
||||||
final imageFile = File(session.imagePath);
|
final imageFile = File(analysis.imagePath);
|
||||||
if (await imageFile.exists()) {
|
if (await imageFile.exists()) {
|
||||||
await imageFile.delete();
|
await imageFile.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _databaseHelper.deleteSession(id);
|
await _databaseHelper.deleteSession(id);
|
||||||
@@ -106,7 +131,84 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getStatistics();
|
return await _databaseHelper.getStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
String generateShotId() {
|
String generateId() {
|
||||||
return _uuid.v4();
|
return _uuid.v4();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weapon Repository methods
|
||||||
|
Future<Weapon> addWeapon({
|
||||||
|
required String name,
|
||||||
|
required WeaponType type,
|
||||||
|
required String caliber,
|
||||||
|
required int magazineCount,
|
||||||
|
required int magazineCapacity,
|
||||||
|
String? notes,
|
||||||
|
String? optic,
|
||||||
|
String? silencer,
|
||||||
|
String? trigger,
|
||||||
|
String? customName,
|
||||||
|
}) async {
|
||||||
|
final weapon = Weapon(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
caliber: caliber,
|
||||||
|
magazineCount: magazineCount,
|
||||||
|
magazineCapacity: magazineCapacity,
|
||||||
|
notes: notes,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
optic: optic,
|
||||||
|
silencer: silencer,
|
||||||
|
trigger: trigger,
|
||||||
|
customName: customName,
|
||||||
|
);
|
||||||
|
await _databaseHelper.insertWeapon(weapon);
|
||||||
|
return weapon;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Weapon>> getWeapons() async {
|
||||||
|
return await _databaseHelper.getAllWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateWeapon(Weapon weapon) async {
|
||||||
|
await _databaseHelper.updateWeapon(weapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteWeapon(String id) async {
|
||||||
|
await _databaseHelper.deleteWeapon(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getRoundsFiredForWeapon(String weaponId) async {
|
||||||
|
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getSessionCountForWeapon(String weaponId) async {
|
||||||
|
return await _databaseHelper.getSessionCountForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addMaintenanceEntry({
|
||||||
|
required String weaponId,
|
||||||
|
required MaintenanceType type,
|
||||||
|
required String description,
|
||||||
|
int? roundsSinceLast,
|
||||||
|
DateTime? date,
|
||||||
|
}) async {
|
||||||
|
final entry = MaintenanceEntry(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
weaponId: weaponId,
|
||||||
|
type: type,
|
||||||
|
description: description,
|
||||||
|
date: date ?? DateTime.now(),
|
||||||
|
roundsSinceLastMaintenance: roundsSinceLast,
|
||||||
|
);
|
||||||
|
await _databaseHelper.insertMaintenance(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MaintenanceEntry>> getMaintenanceHistory(String weaponId) async {
|
||||||
|
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMaintenanceEntry(String id) async {
|
||||||
|
await _databaseHelper.deleteMaintenance(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,47 @@
|
|||||||
/// 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';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../../data/models/session.dart';
|
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,
|
_groupingAnalyzerService = groupingAnalyzerService,
|
||||||
}) : _detectionService = detectionService,
|
_sessionRepository = sessionRepository;
|
||||||
_scoreCalculatorService = scoreCalculatorService,
|
|
||||||
_groupingAnalyzerService = groupingAnalyzerService,
|
|
||||||
_sessionRepository = sessionRepository,
|
|
||||||
_distortionService = distortionService ?? DistortionCorrectionService(),
|
|
||||||
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
|
|
||||||
|
|
||||||
AnalysisState _state = AnalysisState.initial;
|
AnalysisState _state = AnalysisState.initial;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
String? _imagePath;
|
String? _imagePath;
|
||||||
TargetType? _targetType;
|
TargetType? _targetType;
|
||||||
|
|
||||||
// Target detection results
|
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
|
||||||
|
double _cropRotation = 0.0;
|
||||||
|
|
||||||
|
// 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;
|
||||||
@@ -68,20 +59,12 @@ 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;
|
||||||
String? get imagePath => _imagePath;
|
String? get imagePath => _imagePath;
|
||||||
TargetType? get targetType => _targetType;
|
TargetType? get targetType => _targetType;
|
||||||
|
double get cropRotation => _cropRotation; // Getter pour le Plotting
|
||||||
double get targetCenterX => _targetCenterX;
|
double get targetCenterX => _targetCenterX;
|
||||||
double get targetCenterY => _targetCenterY;
|
double get targetCenterY => _targetCenterY;
|
||||||
double get targetRadius => _targetRadius;
|
double get targetRadius => _targetRadius;
|
||||||
@@ -96,30 +79,19 @@ 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
|
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
||||||
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
|
void setCropRotation(double rotation) {
|
||||||
DistortionParameters? get distortionParams => _distortionParams;
|
_cropRotation = rotation;
|
||||||
String? get correctedImagePath => _correctedImagePath;
|
notifyListeners();
|
||||||
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
|
}
|
||||||
|
|
||||||
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
|
/// Charge l'image et initialise les paramètres de cible par défaut.
|
||||||
String? get displayImagePath =>
|
/// Le placement des impacts et la calibration se font ensuite manuellement.
|
||||||
_distortionCorrectionEnabled && _correctedImagePath != null
|
|
||||||
? _correctedImagePath
|
|
||||||
: _imagePath;
|
|
||||||
|
|
||||||
/// Analyze an image
|
|
||||||
///
|
|
||||||
/// [autoAnalyze] determines if we should run automatic detection immediately.
|
|
||||||
/// If false, only the image is loaded and default target parameters are set.
|
|
||||||
Future<void> analyzeImage(
|
Future<void> analyzeImage(
|
||||||
String imagePath,
|
String imagePath,
|
||||||
TargetType targetType, {
|
TargetType targetType, {
|
||||||
bool autoAnalyze = true,
|
Offset? manualCenter,
|
||||||
}) async {
|
}) async {
|
||||||
_state = AnalysisState.loading;
|
_state = AnalysisState.loading;
|
||||||
_imagePath = imagePath;
|
_imagePath = imagePath;
|
||||||
@@ -136,52 +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 = 0.5;
|
_targetRadius = 0.4;
|
||||||
_targetCenterY = 0.5;
|
_targetInnerRadius = 0.04;
|
||||||
_targetRadius = 0.4;
|
|
||||||
_targetInnerRadius = 0.04;
|
|
||||||
|
|
||||||
// Initialize empty shots list
|
_shots = [];
|
||||||
_shots = [];
|
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
|
||||||
notifyListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect target and impacts
|
|
||||||
final result = _detectionService.detectTarget(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,
|
|
||||||
sessionId: '', // Will be set when saving
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Calculate scores
|
|
||||||
_recalculateScores();
|
|
||||||
|
|
||||||
// Calculate grouping
|
|
||||||
_recalculateGrouping();
|
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
_state = AnalysisState.success;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -195,7 +127,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
/// Add a manual shot
|
/// Add a manual shot
|
||||||
void addShot(double x, double y) {
|
void addShot(double x, double y) {
|
||||||
final score = _calculateShotScore(x, y);
|
final score = _calculateShotScore(x, y);
|
||||||
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
|
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
|
||||||
|
|
||||||
_shots.add(shot);
|
_shots.add(shot);
|
||||||
_recalculateScores();
|
_recalculateScores();
|
||||||
@@ -203,6 +135,15 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Plotting).
|
||||||
|
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
|
||||||
|
void clearShots() {
|
||||||
|
_shots.clear();
|
||||||
|
_recalculateScores();
|
||||||
|
_recalculateGrouping();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a shot
|
/// Remove a shot
|
||||||
void removeShot(String shotId) {
|
void removeShot(String shotId) {
|
||||||
_shots.removeWhere((shot) => shot.id == shotId);
|
_shots.removeWhere((shot) => shot.id == shotId);
|
||||||
@@ -211,19 +152,6 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a shot to a new position
|
|
||||||
void moveShot(String shotId, double newX, double newY) {
|
|
||||||
final index = _shots.indexWhere((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();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a shot's score manually
|
/// Update a shot's score manually
|
||||||
void updateShotScore(String shotId, int newScore) {
|
void updateShotScore(String shotId, int newScore) {
|
||||||
final index = _shots.indexWhere((shot) => shot.id == shotId);
|
final index = _shots.indexWhere((shot) => shot.id == shotId);
|
||||||
@@ -234,273 +162,6 @@ 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,
|
|
||||||
sessionId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
|
|
||||||
///
|
|
||||||
/// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes
|
|
||||||
/// de compilation. Cette méthode retourne 0 (aucun impact détecté).
|
|
||||||
/// Utiliser autoDetectImpacts() à la place.
|
|
||||||
///
|
|
||||||
/// Utilise les algorithmes OpenCV pour une détection plus robuste:
|
|
||||||
/// - Transformation de Hough pour détecter les cercles
|
|
||||||
/// - Analyse de contours avec filtrage par circularité
|
|
||||||
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,
|
|
||||||
sessionId: '',
|
|
||||||
);
|
|
||||||
_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,
|
|
||||||
sessionId: '',
|
|
||||||
);
|
|
||||||
_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, sessionId: '');
|
|
||||||
_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,
|
|
||||||
sessionId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjust target position
|
/// Adjust target position
|
||||||
void adjustTargetPosition(
|
void adjustTargetPosition(
|
||||||
double centerX,
|
double centerX,
|
||||||
@@ -509,17 +170,20 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
double radius, {
|
double radius, {
|
||||||
int? ringCount,
|
int? ringCount,
|
||||||
List<double>? ringRadii,
|
List<double>? ringRadii,
|
||||||
|
double zoomScale = 1.0,
|
||||||
|
Offset offset = Offset.zero,
|
||||||
}) {
|
}) {
|
||||||
_targetCenterX = centerX;
|
_targetCenterX = (centerX - offset.dx) / zoomScale;
|
||||||
_targetCenterY = centerY;
|
_targetCenterY = (centerY - offset.dy) / zoomScale;
|
||||||
_targetInnerRadius = innerRadius;
|
_targetRadius = radius / zoomScale;
|
||||||
_targetRadius = radius;
|
_targetInnerRadius = innerRadius / zoomScale;
|
||||||
|
|
||||||
if (ringCount != null) {
|
if (ringCount != null) {
|
||||||
_ringCount = ringCount;
|
_ringCount = ringCount;
|
||||||
}
|
}
|
||||||
if (ringRadii != null) {
|
|
||||||
_ringRadii = ringRadii;
|
// CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement !
|
||||||
}
|
_ringRadii = ringRadii;
|
||||||
|
|
||||||
// Recalculate all shot scores based on new target position
|
// Recalculate all shot scores based on new target position
|
||||||
_shots = _shots.map((shot) {
|
_shots = _shots.map((shot) {
|
||||||
@@ -531,121 +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; // The corrected image is always square (side x side)
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Detect the target on the straight/corrected image
|
|
||||||
final result = await _opencvTargetService.detectTarget(_imagePath!);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
adjustTargetPosition(
|
|
||||||
result.centerX,
|
|
||||||
result.centerY,
|
|
||||||
result.radius * 0.1,
|
|
||||||
result.radius,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
|
||||||
print('Auto-calibration error: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* version deux a tester*/
|
|
||||||
/// 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(); // On prévient quand même si pas de correction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> runFullDistortionWorkflow() async {
|
|
||||||
_state = AnalysisState.loading; // Affiche un spinner sur votre UI
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
calculateDistortion(); // Calcule les paramètres
|
|
||||||
await applyDistortionCorrection(); // Génère le fichier corrigé
|
|
||||||
_distortionCorrectionEnabled = true; // Active l'affichage
|
|
||||||
_state = AnalysisState.success;
|
|
||||||
} catch (e) {
|
|
||||||
_errorMessage = "Erreur de rendu : $e";
|
|
||||||
_state = AnalysisState.error;
|
|
||||||
} finally {
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* fin section deux a tester*/
|
|
||||||
|
|
||||||
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(
|
||||||
@@ -689,32 +238,35 @@ 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'exporter : image ou type de cible manquant.";
|
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identifiant de session temporaire si non sauvegardée
|
final service = AiExportService();
|
||||||
final sessionId = _shots.isNotEmpty && _shots.first.sessionId.isNotEmpty
|
|
||||||
? _shots.first.sessionId
|
|
||||||
: 'session_${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
|
|
||||||
final service = AiExportService(); // Local instanciation for simplicity
|
|
||||||
|
|
||||||
_state = AnalysisState.loading;
|
_state = AnalysisState.loading;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final success = await service.exportData(
|
final success = await service.exportData(
|
||||||
imagePath: _imagePath!,
|
imagePath: _imagePath!,
|
||||||
sessionId: sessionId,
|
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;
|
||||||
@@ -726,15 +278,23 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save the session
|
/// Save the session
|
||||||
Future<Session> saveSession({String? notes}) async {
|
Future<TargetAnalysis> saveSession({
|
||||||
|
String? notes,
|
||||||
|
String? sessionId,
|
||||||
|
String? weaponName,
|
||||||
|
String? weaponId,
|
||||||
|
int? distance,
|
||||||
|
DateTime? date,
|
||||||
|
}) async {
|
||||||
if (_imagePath == null || _targetType == null) {
|
if (_imagePath == null || _targetType == null) {
|
||||||
throw Exception('Cannot save: missing image or target type');
|
throw Exception('Cannot save: missing image or target type');
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = await _sessionRepository.createSession(
|
final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
|
||||||
targetType: _targetType!,
|
|
||||||
imagePath: _imagePath!,
|
imagePath: _imagePath!,
|
||||||
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(),
|
sessionId: sessionId ?? 'standalone',
|
||||||
|
targetType: _targetType!,
|
||||||
|
shots: _shots,
|
||||||
totalScore: totalScore,
|
totalScore: totalScore,
|
||||||
groupingDiameter: _groupingResult?.diameter,
|
groupingDiameter: _groupingResult?.diameter,
|
||||||
groupingCenterX: _groupingResult?.centerX,
|
groupingCenterX: _groupingResult?.centerX,
|
||||||
@@ -745,11 +305,41 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
targetRadius: _targetRadius,
|
targetRadius: _targetRadius,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update shots with session ID
|
if (sessionId != null && sessionId != 'standalone') {
|
||||||
_shots = session.shots;
|
final existingSession = await _sessionRepository.getSession(sessionId);
|
||||||
notifyListeners();
|
if (existingSession != null) {
|
||||||
|
await _sessionRepository.addAnalysisToSession(
|
||||||
|
sessionId,
|
||||||
|
targetAnalysis,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// CORRECTION : Utilise la date injectée plutôt que DateTime.now()
|
||||||
|
await _sessionRepository.createSession(
|
||||||
|
id: sessionId,
|
||||||
|
weapon: weaponName ?? 'Inconnue',
|
||||||
|
weaponId: weaponId,
|
||||||
|
maxShotsPerTarget: shotCount,
|
||||||
|
analyses: [targetAnalysis],
|
||||||
|
notes: notes,
|
||||||
|
distance: distance ?? 25,
|
||||||
|
date: date ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// CORRECTION : S'applique aussi au flux alternatif sans ID préalable
|
||||||
|
await _sessionRepository.createSession(
|
||||||
|
weapon: weaponName ?? 'Inconnue',
|
||||||
|
weaponId: weaponId,
|
||||||
|
maxShotsPerTarget: shotCount,
|
||||||
|
analyses: [targetAnalysis],
|
||||||
|
notes: notes,
|
||||||
|
distance: distance ?? 25,
|
||||||
|
date: date ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return session;
|
notifyListeners();
|
||||||
|
return targetAnalysis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the provider
|
/// Reset the provider
|
||||||
@@ -758,6 +348,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_imagePath = null;
|
_imagePath = null;
|
||||||
_targetType = null;
|
_targetType = null;
|
||||||
|
_cropRotation = 0.0;
|
||||||
_targetCenterX = 0.5;
|
_targetCenterX = 0.5;
|
||||||
_targetCenterY = 0.5;
|
_targetCenterY = 0.5;
|
||||||
_targetRadius = 0.4;
|
_targetRadius = 0.4;
|
||||||
@@ -768,11 +359,25 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Met à jour la position d'un impact de tir en direct (Glisser-Déposer).
|
||||||
|
void updateShotPosition(String shotId, double newX, double newY) {
|
||||||
|
final index = _shots.indexWhere((s) => s.id == shotId);
|
||||||
|
if (index != -1) {
|
||||||
|
// 1. On calcule dynamiquement le nouveau score de la zone survolée
|
||||||
|
final newScore = _calculateShotScore(newX, newY);
|
||||||
|
|
||||||
|
// 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés
|
||||||
|
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
|
||||||
|
|
||||||
|
// 3. On applique les deux méthodes natives de recalcul de ton application
|
||||||
|
_recalculateScores();
|
||||||
|
_recalculateGrouping();
|
||||||
|
|
||||||
|
// 4. On demande à l'écran de se redessiner en direct
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
209
lib/features/analysis/impact_editor_screen.dart
Normal file
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
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';
|
||||||
@@ -18,13 +18,13 @@ class TargetCalibration extends StatefulWidget {
|
|||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
final List<double>? initialRingRadii;
|
final List<double>? initialRingRadii;
|
||||||
final Function(
|
final Function(
|
||||||
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,
|
||||||
})
|
})
|
||||||
onCalibrationChanged;
|
onCalibrationChanged;
|
||||||
|
|
||||||
const TargetCalibration({
|
const TargetCalibration({
|
||||||
@@ -40,10 +40,10 @@ class TargetCalibration extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TargetCalibration> createState() => _TargetCalibrationState();
|
State<TargetCalibration> createState() => TargetCalibrationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TargetCalibrationState extends State<TargetCalibration> {
|
class TargetCalibrationState extends State<TargetCalibration> {
|
||||||
late double _centerX;
|
late double _centerX;
|
||||||
late double _centerY;
|
late double _centerY;
|
||||||
late double _radius;
|
late double _radius;
|
||||||
@@ -51,10 +51,19 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
late int _ringCount;
|
late int _ringCount;
|
||||||
late List<double> _ringRadii;
|
late List<double> _ringRadii;
|
||||||
|
|
||||||
|
// Variable dédiée pour piloter la jauge orange sans conflit
|
||||||
|
late double _currentEspacementRatio;
|
||||||
|
|
||||||
bool _isDraggingCenter = false;
|
bool _isDraggingCenter = false;
|
||||||
bool _isDraggingRadius = false;
|
bool _isDraggingRadius = false;
|
||||||
bool _isDraggingInnerRadius = false;
|
bool _isDraggingInnerRadius = false;
|
||||||
|
|
||||||
|
bool _showEspacement = false;
|
||||||
|
double _baseRadiusBeforeScale = 1.0;
|
||||||
|
|
||||||
|
// SAUVEGARDE CRITIQUE : Pour mémoriser la structure d'origine de l'IA
|
||||||
|
List<double>? _originalRingRadii;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -63,19 +72,37 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
_radius = widget.initialRadius;
|
_radius = widget.initialRadius;
|
||||||
_innerRadius = widget.initialInnerRadius;
|
_innerRadius = widget.initialInnerRadius;
|
||||||
_ringCount = widget.initialRingCount;
|
_ringCount = widget.initialRingCount;
|
||||||
|
|
||||||
|
// On mémorise la configuration d'usine de l'image
|
||||||
|
if (widget.initialRingRadii != null) {
|
||||||
|
_originalRingRadii = List.from(widget.initialRingRadii!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialisation du ratio par défaut
|
||||||
|
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||||
_initRingRadii();
|
_initRingRadii();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initRingRadii() {
|
/// Fige et propage la calibration courante vers le provider.
|
||||||
if (widget.initialRingRadii != null &&
|
///
|
||||||
widget.initialRingRadii!.length == _ringCount) {
|
/// 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}) {
|
||||||
|
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
|
||||||
|
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
|
||||||
|
_ringRadii = List.from(_originalRingRadii!);
|
||||||
|
} else if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
|
||||||
_ringRadii = List.from(widget.initialRingRadii!);
|
_ringRadii = List.from(widget.initialRingRadii!);
|
||||||
} else {
|
} else {
|
||||||
// Initialize with default proportional radii interpolated between inner and outer
|
|
||||||
_ringRadii = List.generate(_ringCount, (i) {
|
_ringRadii = List.generate(_ringCount, (i) {
|
||||||
if (_ringCount <= 1) return 1.0;
|
if (_ringCount <= 1) return 1.0;
|
||||||
final ratio = _innerRadius / _radius;
|
return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
|
||||||
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,12 +112,10 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
bool shouldReinit = false;
|
bool shouldReinit = false;
|
||||||
|
|
||||||
if (widget.initialCenterX != oldWidget.initialCenterX &&
|
if (widget.initialCenterX != oldWidget.initialCenterX && !_isDraggingCenter) {
|
||||||
!_isDraggingCenter) {
|
|
||||||
_centerX = widget.initialCenterX;
|
_centerX = widget.initialCenterX;
|
||||||
}
|
}
|
||||||
if (widget.initialCenterY != oldWidget.initialCenterY &&
|
if (widget.initialCenterY != oldWidget.initialCenterY && !_isDraggingCenter) {
|
||||||
!_isDraggingCenter) {
|
|
||||||
_centerY = widget.initialCenterY;
|
_centerY = widget.initialCenterY;
|
||||||
}
|
}
|
||||||
if (widget.initialRingCount != oldWidget.initialRingCount) {
|
if (widget.initialRingCount != oldWidget.initialRingCount) {
|
||||||
@@ -101,12 +126,20 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
_radius = widget.initialRadius;
|
_radius = widget.initialRadius;
|
||||||
shouldReinit = true;
|
shouldReinit = true;
|
||||||
}
|
}
|
||||||
if (widget.initialInnerRadius != oldWidget.initialInnerRadius &&
|
if (widget.initialInnerRadius != oldWidget.initialInnerRadius && !_isDraggingInnerRadius) {
|
||||||
!_isDraggingInnerRadius) {
|
|
||||||
_innerRadius = widget.initialInnerRadius;
|
_innerRadius = widget.initialInnerRadius;
|
||||||
|
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||||
shouldReinit = true;
|
shouldReinit = true;
|
||||||
}
|
}
|
||||||
if (widget.initialRingRadii != oldWidget.initialRingRadii) {
|
// 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!);
|
||||||
shouldReinit = true;
|
shouldReinit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,107 +154,246 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final size = constraints.biggest;
|
final size = constraints.biggest;
|
||||||
|
|
||||||
return GestureDetector(
|
return Stack(
|
||||||
onPanStart: (details) => _onPanStart(details, size),
|
children: [
|
||||||
onPanUpdate: (details) => _onPanUpdate(details, size),
|
GestureDetector(
|
||||||
onPanEnd: (_) => _onPanEnd(),
|
onScaleStart: (details) {
|
||||||
child: CustomPaint(
|
_baseRadiusBeforeScale = _radius;
|
||||||
size: size,
|
final tapX = details.localFocalPoint.dx / size.width;
|
||||||
painter: _CalibrationPainter(
|
final tapY = details.localFocalPoint.dy / size.height;
|
||||||
centerX: _centerX,
|
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
||||||
centerY: _centerY,
|
|
||||||
radius: _radius,
|
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
||||||
innerRadius: _innerRadius,
|
setState(() {
|
||||||
ringCount: _ringCount,
|
_isDraggingCenter = true;
|
||||||
ringRadii: _ringRadii,
|
});
|
||||||
targetType: widget.targetType,
|
}
|
||||||
isDraggingCenter: _isDraggingCenter,
|
},
|
||||||
isDraggingRadius: _isDraggingRadius,
|
onScaleUpdate: (details) => _onScaleUpdate(details, size),
|
||||||
isDraggingInnerRadius: _isDraggingInnerRadius,
|
onScaleEnd: (_) => _onScaleEnd(),
|
||||||
|
child: CustomPaint(
|
||||||
|
size: size,
|
||||||
|
painter: _CalibrationPainter(
|
||||||
|
centerX: _centerX,
|
||||||
|
centerY: _centerY,
|
||||||
|
radius: _radius,
|
||||||
|
innerRadius: _innerRadius,
|
||||||
|
ringCount: _ringCount,
|
||||||
|
ringRadii: _ringRadii,
|
||||||
|
targetType: widget.targetType,
|
||||||
|
isDraggingCenter: _isDraggingCenter,
|
||||||
|
isDraggingRadius: false,
|
||||||
|
isDraggingInnerRadius: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
Positioned(
|
||||||
|
top: 10,
|
||||||
|
left: 40,
|
||||||
|
right: 40,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Options d\'espacement avancées',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 28,
|
||||||
|
child: Switch(
|
||||||
|
value: _showEspacement,
|
||||||
|
activeThumbColor: const Color(0xFF00FF00),
|
||||||
|
onChanged: (bool value) {
|
||||||
|
setState(() {
|
||||||
|
_showEspacement = value;
|
||||||
|
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine !
|
||||||
|
if (!value) {
|
||||||
|
_initRingRadii(forceRecalculate: false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Slider pour la taille (toujours visible)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
// SÉCURITÉ : Ouverture mesurée des bornes pour plus de liberté sans débordement incontrôlé
|
||||||
|
value: _radius.clamp(0.3, 0.95),
|
||||||
|
min: 0.3,
|
||||||
|
max: 0.95,
|
||||||
|
activeColor: AppTheme.primaryColor,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_radius = value;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
// CORRECTION : Si le mode avancé n'est pas coché, on applique la taille pure sans détruire le ratio d'origine
|
||||||
|
_initRingRadii(forceRecalculate: _showEspacement);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Affichage conditionnel du slider d'espacement orange
|
||||||
|
if (_showEspacement) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
const Icon(Icons.compress, color: Colors.white, size: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _currentEspacementRatio,
|
||||||
|
min: 0.01,
|
||||||
|
// SÉCURITÉ : Écartement max bridé à 0.70 pour confiner le dernier cercle
|
||||||
|
max: 0.70,
|
||||||
|
activeColor: Colors.orange,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_currentEspacementRatio = value;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: true);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.expand, color: Colors.white, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// CORRECTION DU BOUTON RESET : Restaure désormais le vrai profil d'usine de l'IA
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_originalRingRadii != null) {
|
||||||
|
_initRingRadii(forceRecalculate: false);
|
||||||
|
if (_ringRadii.isNotEmpty) {
|
||||||
|
_currentEspacementRatio = _ringRadii.first;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_currentEspacementRatio = 0.1;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
},
|
||||||
|
tooltip: 'Réinitialiser l\'espacement',
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanStart(DragStartDetails details, Size size) {
|
Widget buildDirectionalControls(BuildContext context, Size size) {
|
||||||
final tapX = details.localPosition.dx / size.width;
|
return Container(
|
||||||
final tapY = details.localPosition.dy / size.height;
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
// Check if tapping on center handle
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
// Check if tapping on outer radius handle
|
),
|
||||||
final minDim = math.min(size.width, size.height);
|
child: Row(
|
||||||
final outerRadius = _radius;
|
mainAxisSize: MainAxisSize.min,
|
||||||
final radiusHandleX = _centerX + outerRadius * minDim / size.width;
|
children: [
|
||||||
final radiusHandleY = _centerY;
|
_buildSignLabel('−'),
|
||||||
final distToOuterHandle = _distance(
|
const SizedBox(width: 12),
|
||||||
tapX,
|
Column(
|
||||||
tapY,
|
mainAxisSize: MainAxisSize.min,
|
||||||
radiusHandleX.clamp(0.0, 1.0),
|
children: [
|
||||||
radiusHandleY.clamp(0.0, 1.0),
|
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
||||||
|
Row(
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildSignLabel('+'),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if tapping on inner radius handle (top edge of innermost circle)
|
|
||||||
final actualInnerRadius = _innerRadius;
|
|
||||||
final innerHandleX = _centerX;
|
|
||||||
final innerHandleY = _centerY - actualInnerRadius * minDim / size.height;
|
|
||||||
final distToInnerHandle = _distance(
|
|
||||||
tapX,
|
|
||||||
tapY,
|
|
||||||
innerHandleX.clamp(0.0, 1.0),
|
|
||||||
innerHandleY.clamp(0.0, 1.0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Increase touch target size slightly for handles
|
|
||||||
if (distToCenter < 0.05) {
|
|
||||||
setState(() {
|
|
||||||
_isDraggingCenter = true;
|
|
||||||
});
|
|
||||||
} else if (distToOuterHandle < 0.05) {
|
|
||||||
setState(() {
|
|
||||||
_isDraggingRadius = true;
|
|
||||||
});
|
|
||||||
} else if (distToInnerHandle < 0.05) {
|
|
||||||
setState(() {
|
|
||||||
_isDraggingInnerRadius = true;
|
|
||||||
});
|
|
||||||
} else if (distToCenter < _radius + 0.02) {
|
|
||||||
// Tapping inside the target - move center
|
|
||||||
setState(() {
|
|
||||||
_isDraggingCenter = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanUpdate(DragUpdateDetails details, Size size) {
|
// Symbole purement décoratif affiché de part et d'autre de la croix.
|
||||||
final deltaX = details.delta.dx / size.width;
|
Widget _buildSignLabel(String text) {
|
||||||
final deltaY = details.delta.dy / size.height;
|
return Text(
|
||||||
final minDim = math.min(size.width, size.height);
|
text,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
||||||
if (_isDraggingCenter) {
|
return Container(
|
||||||
// Move center
|
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||||
_centerX = _centerX + deltaX;
|
margin: const EdgeInsets.all(2),
|
||||||
_centerY = _centerY + deltaY;
|
child: IconButton(
|
||||||
} else if (_isDraggingRadius) {
|
icon: Icon(icon, color: Colors.white, size: 28),
|
||||||
// Adjust outer radius
|
onPressed: onPressed,
|
||||||
final newRadius = _radius + deltaX * (size.width / minDim);
|
padding: EdgeInsets.zero,
|
||||||
_radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0);
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
_initRingRadii(); // Recalculate linear separation
|
),
|
||||||
} else if (_isDraggingInnerRadius) {
|
);
|
||||||
// Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY)
|
}
|
||||||
final newInnerRadius = _innerRadius - deltaY * (size.height / minDim);
|
|
||||||
_innerRadius = newInnerRadius.clamp(
|
|
||||||
0.01,
|
|
||||||
math.max(0.01, _radius - 0.01),
|
|
||||||
);
|
|
||||||
_initRingRadii(); // Recalculate linear separation
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
void _notifyChange() {
|
||||||
widget.onCalibrationChanged(
|
widget.onCalibrationChanged(
|
||||||
_centerX,
|
_centerX,
|
||||||
_centerY,
|
_centerY,
|
||||||
@@ -232,7 +404,31 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanEnd() {
|
void _moveCenterByPixels(double dx, double dy, Size size) {
|
||||||
|
setState(() {
|
||||||
|
_centerX = _centerX + (dx / size.width);
|
||||||
|
_centerY = _centerY + (dy / size.height);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
|
||||||
|
setState(() {
|
||||||
|
if (details.pointerCount == 2) {
|
||||||
|
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95);
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: _showEspacement);
|
||||||
|
} else if (_isDraggingCenter) {
|
||||||
|
final deltaX = details.focalPointDelta.dx / size.width;
|
||||||
|
final deltaY = details.focalPointDelta.dy / size.height;
|
||||||
|
_centerX = _centerX + deltaX;
|
||||||
|
_centerY = _centerY + deltaY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScaleEnd() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingCenter = false;
|
_isDraggingCenter = false;
|
||||||
_isDraggingRadius = false;
|
_isDraggingRadius = false;
|
||||||
@@ -277,7 +473,6 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
final centerPx = Offset(centerX * size.width, centerY * size.height);
|
final centerPx = Offset(centerX * size.width, centerY * size.height);
|
||||||
final minDim = size.width < size.height ? size.width : size.height;
|
final minDim = size.width < size.height ? size.width : size.height;
|
||||||
final baseRadiusPx = radius * minDim;
|
final baseRadiusPx = radius * minDim;
|
||||||
final innerRadiusPx = innerRadius * minDim;
|
|
||||||
|
|
||||||
if (targetType == TargetType.concentric) {
|
if (targetType == TargetType.concentric) {
|
||||||
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
|
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
|
||||||
@@ -285,43 +480,17 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
|
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fullscreen crosshairs when dragging center
|
|
||||||
if (isDraggingCenter) {
|
if (isDraggingCenter) {
|
||||||
final crosshairLinePaint = Paint()
|
final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
|
||||||
..color = AppTheme.successColor.withValues(alpha: 0.5)
|
canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
|
||||||
..strokeWidth = 1;
|
canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
|
||||||
canvas.drawLine(
|
|
||||||
Offset(0, centerPx.dy),
|
|
||||||
Offset(size.width, centerPx.dy),
|
|
||||||
crosshairLinePaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(centerPx.dx, 0),
|
|
||||||
Offset(centerPx.dx, size.height),
|
|
||||||
crosshairLinePaint,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw center handle
|
|
||||||
_drawCenterHandle(canvas, centerPx);
|
_drawCenterHandle(canvas, centerPx);
|
||||||
|
|
||||||
// Draw radius handle (for outer ring)
|
|
||||||
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
|
|
||||||
|
|
||||||
// Draw inner radius handle
|
|
||||||
_drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx);
|
|
||||||
|
|
||||||
// Draw instructions
|
|
||||||
_drawInstructions(canvas, size);
|
_drawInstructions(canvas, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawConcentricZones(
|
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
|
||||||
Canvas canvas,
|
|
||||||
Size size,
|
|
||||||
Offset center,
|
|
||||||
double baseRadius,
|
|
||||||
) {
|
|
||||||
// Generate colors for zones
|
|
||||||
List<Color> zoneColors = [];
|
List<Color> zoneColors = [];
|
||||||
for (int i = 0; i < ringCount; i++) {
|
for (int i = 0; i < ringCount; i++) {
|
||||||
final ratio = i / ringCount;
|
final ratio = i / ringCount;
|
||||||
@@ -342,13 +511,10 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
final strokePaint = Paint()
|
final strokePaint = Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 1
|
..strokeWidth = 1
|
||||||
..color = Colors.white.withValues(alpha: 0.6);
|
..color = Colors.red.withValues(alpha: 0.8);
|
||||||
|
|
||||||
// Draw from outside to inside
|
|
||||||
for (int i = ringCount - 1; i >= 0; i--) {
|
for (int i = ringCount - 1; i >= 0; i--) {
|
||||||
final ringRadius = ringRadii.length > i
|
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
|
||||||
? ringRadii[i]
|
|
||||||
: (i + 1) / ringCount;
|
|
||||||
final zoneRadius = baseRadius * ringRadius;
|
final zoneRadius = baseRadius * ringRadius;
|
||||||
|
|
||||||
zonePaint.color = zoneColors[i];
|
zonePaint.color = zoneColors[i];
|
||||||
@@ -356,22 +522,14 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
canvas.drawCircle(center, zoneRadius, strokePaint);
|
canvas.drawCircle(center, zoneRadius, strokePaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw zone labels (only if within visible area)
|
|
||||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||||
|
|
||||||
for (int i = 0; i < ringCount; i++) {
|
for (int i = 0; i < ringCount; i++) {
|
||||||
final ringRadius = ringRadii.length > i
|
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
|
||||||
? ringRadii[i]
|
final textSpanRatio = i > 0 ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) : 0.0;
|
||||||
: (i + 1) / ringCount;
|
final zoneRadius = baseRadius * (ringRadius + textSpanRatio) / 2;
|
||||||
final prevRingRadius = i > 0
|
|
||||||
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
|
|
||||||
: 0.0;
|
|
||||||
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
|
|
||||||
|
|
||||||
// Score: center = 10, decrement by 1 for each ring
|
|
||||||
final score = 10 - i;
|
final score = 10 - i;
|
||||||
|
|
||||||
// Only draw label if it's within the visible area
|
|
||||||
final labelX = center.dx + zoneRadius;
|
final labelX = center.dx + zoneRadius;
|
||||||
if (labelX < 0 || labelX > size.width) continue;
|
if (labelX < 0 || labelX > size.width) continue;
|
||||||
|
|
||||||
@@ -386,251 +544,49 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
);
|
);
|
||||||
textPainter.layout();
|
textPainter.layout();
|
||||||
|
|
||||||
// Draw label on the right side of each zone
|
|
||||||
final labelY = center.dy - textPainter.height / 2;
|
final labelY = center.dy - textPainter.height / 2;
|
||||||
if (labelY >= 0 && labelY <= size.height) {
|
if (labelY >= 0 && labelY <= size.height) {
|
||||||
textPainter.paint(
|
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
|
||||||
canvas,
|
|
||||||
Offset(labelX - textPainter.width / 2, labelY),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawSilhouetteZones(
|
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
|
||||||
Canvas canvas,
|
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
|
||||||
Size size,
|
|
||||||
Offset center,
|
|
||||||
double radius,
|
|
||||||
) {
|
|
||||||
// Simplified silhouette zones
|
|
||||||
final paint = Paint()
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 2;
|
|
||||||
|
|
||||||
// Draw silhouette outline (simplified as rectangle for now)
|
|
||||||
final silhouetteWidth = radius * 0.8;
|
final silhouetteWidth = radius * 0.8;
|
||||||
final silhouetteHeight = radius * 2;
|
final silhouetteHeight = radius * 2;
|
||||||
|
|
||||||
paint.color = Colors.green.withValues(alpha: 0.5);
|
paint.color = Colors.green.withValues(alpha: 0.5);
|
||||||
canvas.drawRect(
|
canvas.drawRect(Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), paint);
|
||||||
Rect.fromCenter(
|
|
||||||
center: center,
|
|
||||||
width: silhouetteWidth,
|
|
||||||
height: silhouetteHeight,
|
|
||||||
),
|
|
||||||
paint,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCenterHandle(Canvas canvas, Offset center) {
|
void _drawCenterHandle(Canvas canvas, Offset center) {
|
||||||
// Outer circle
|
final outerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.stroke..strokeWidth = 3;
|
||||||
final outerPaint = Paint()
|
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 3;
|
|
||||||
canvas.drawCircle(center, 15, outerPaint);
|
canvas.drawCircle(center, 15, outerPaint);
|
||||||
|
|
||||||
// Inner dot
|
final innerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.fill;
|
||||||
final innerPaint = Paint()
|
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
canvas.drawCircle(center, 5, innerPaint);
|
canvas.drawCircle(center, 5, innerPaint);
|
||||||
|
|
||||||
// Crosshair
|
final crossPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..strokeWidth = 2;
|
||||||
final crossPaint = Paint()
|
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
|
||||||
..strokeWidth = 2;
|
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
|
||||||
canvas.drawLine(
|
canvas.drawLine(Offset(center.dx, center.dy + 4), Offset(center.dx, center.dy + 20), crossPaint);
|
||||||
Offset(center.dx - 20, center.dy),
|
|
||||||
Offset(center.dx - 8, center.dy),
|
|
||||||
crossPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(center.dx + 8, center.dy),
|
|
||||||
Offset(center.dx + 20, center.dy),
|
|
||||||
crossPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(center.dx, center.dy - 20),
|
|
||||||
Offset(center.dx, center.dy - 8),
|
|
||||||
crossPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(center.dx, center.dy + 8),
|
|
||||||
Offset(center.dx, center.dy + 20),
|
|
||||||
crossPaint,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawRadiusHandle(
|
|
||||||
Canvas canvas,
|
|
||||||
Size size,
|
|
||||||
Offset center,
|
|
||||||
double baseRadius,
|
|
||||||
) {
|
|
||||||
// Radius handle on the right edge of the outermost ring
|
|
||||||
final actualHandleX = center.dx + baseRadius;
|
|
||||||
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
|
|
||||||
final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
|
|
||||||
final handlePos = Offset(clampedHandleX, clampedHandleY);
|
|
||||||
|
|
||||||
// Check if handle is clamped (radius extends beyond visible area)
|
|
||||||
final isClamped = actualHandleX > size.width - 20;
|
|
||||||
|
|
||||||
final paint = Paint()
|
|
||||||
..color = isDraggingRadius
|
|
||||||
? AppTheme.successColor
|
|
||||||
: (isClamped ? Colors.orange : AppTheme.warningColor)
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
|
|
||||||
// Draw handle as a small circle with arrows
|
|
||||||
canvas.drawCircle(handlePos, 14, paint);
|
|
||||||
|
|
||||||
// Draw arrow indicators
|
|
||||||
final arrowPaint = Paint()
|
|
||||||
..color = Colors.white
|
|
||||||
..strokeWidth = 2
|
|
||||||
..style = PaintingStyle.stroke;
|
|
||||||
|
|
||||||
// Left arrow
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx - 4, handlePos.dy),
|
|
||||||
Offset(handlePos.dx - 8, handlePos.dy - 4),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx - 4, handlePos.dy),
|
|
||||||
Offset(handlePos.dx - 8, handlePos.dy + 4),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Right arrow
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx + 4, handlePos.dy),
|
|
||||||
Offset(handlePos.dx + 8, handlePos.dy - 4),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx + 4, handlePos.dy),
|
|
||||||
Offset(handlePos.dx + 8, handlePos.dy + 4),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Label
|
|
||||||
final textPainter = TextPainter(
|
|
||||||
text: const TextSpan(
|
|
||||||
text: 'EXT.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(
|
|
||||||
canvas,
|
|
||||||
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawInnerRadiusHandle(
|
|
||||||
Canvas canvas,
|
|
||||||
Size size,
|
|
||||||
Offset center,
|
|
||||||
double innerRadiusPx,
|
|
||||||
) {
|
|
||||||
// Inner radius handle on the top edge of the innermost ring
|
|
||||||
final actualHandleY = center.dy - innerRadiusPx;
|
|
||||||
final clampedHandleX = center.dx.clamp(20.0, size.width - 20);
|
|
||||||
final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20);
|
|
||||||
final handlePos = Offset(clampedHandleX, clampedHandleY);
|
|
||||||
|
|
||||||
final isClamped = actualHandleY < 20.0;
|
|
||||||
|
|
||||||
final paint = Paint()
|
|
||||||
..color = isDraggingInnerRadius
|
|
||||||
? AppTheme.successColor
|
|
||||||
: (isClamped ? Colors.orange : Colors.purpleAccent)
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
|
|
||||||
// Draw handle
|
|
||||||
canvas.drawCircle(handlePos, 14, paint);
|
|
||||||
|
|
||||||
// Up/Down arrow indicators
|
|
||||||
final arrowPaint = Paint()
|
|
||||||
..color = Colors.white
|
|
||||||
..strokeWidth = 2
|
|
||||||
..style = PaintingStyle.stroke;
|
|
||||||
|
|
||||||
// Up arrow
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx, handlePos.dy - 4),
|
|
||||||
Offset(handlePos.dx - 4, handlePos.dy - 8),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx, handlePos.dy - 4),
|
|
||||||
Offset(handlePos.dx + 4, handlePos.dy - 8),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Down arrow
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx, handlePos.dy + 4),
|
|
||||||
Offset(handlePos.dx - 4, handlePos.dy + 8),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx, handlePos.dy + 4),
|
|
||||||
Offset(handlePos.dx + 4, handlePos.dy + 8),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Label
|
|
||||||
final textPainter = TextPainter(
|
|
||||||
text: const TextSpan(
|
|
||||||
text: 'INT.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(
|
|
||||||
canvas,
|
|
||||||
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy - 24),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawInstructions(Canvas canvas, Size size) {
|
void _drawInstructions(Canvas canvas, Size size) {
|
||||||
const instruction = 'Deplacez le centre ou ajustez le rayon';
|
const instruction = 'Deplacez le centre ou ajustez le rayon';
|
||||||
|
|
||||||
final textPainter = TextPainter(
|
final textPainter = TextPainter(
|
||||||
text: TextSpan(
|
text: TextSpan(text: instruction, style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 12, backgroundColor: Colors.black54)),
|
||||||
text: instruction,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
fontSize: 12,
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
textDirection: TextDirection.ltr,
|
||||||
);
|
);
|
||||||
textPainter.layout();
|
textPainter.layout();
|
||||||
textPainter.paint(
|
textPainter.paint(canvas, Offset((size.width - textPainter.width) / 2, size.height - 30));
|
||||||
canvas,
|
|
||||||
Offset((size.width - textPainter.width) / 2, size.height - 30),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) {
|
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||||
|
if (oldDelegate is! _CalibrationPainter) return true;
|
||||||
return centerX != oldDelegate.centerX ||
|
return centerX != oldDelegate.centerX ||
|
||||||
centerY != oldDelegate.centerY ||
|
centerY != oldDelegate.centerY ||
|
||||||
radius != oldDelegate.radius ||
|
radius != oldDelegate.radius ||
|
||||||
@@ -641,4 +597,4 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
|
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
|
||||||
ringRadii != oldDelegate.ringRadii;
|
ringRadii != oldDelegate.ringRadii;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,8 +316,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,7 @@
|
|||||||
/// Écran de recadrage d'image en format carré (1:1).
|
|
||||||
///
|
|
||||||
/// Permet à l'utilisateur de déplacer et zoomer l'image pour sélectionner
|
|
||||||
/// la zone à recadrer. Le carré de recadrage est fixe au centre de l'écran.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui;
|
|
||||||
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';
|
||||||
import '../../services/image_crop_service.dart';
|
import '../../services/image_crop_service.dart';
|
||||||
@@ -17,11 +11,15 @@ import 'widgets/crop_overlay.dart';
|
|||||||
class CropScreen extends StatefulWidget {
|
class CropScreen extends StatefulWidget {
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
|
final double? initialScale;
|
||||||
|
final Offset? initialOffset;
|
||||||
|
|
||||||
const CropScreen({
|
const CropScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imagePath,
|
required this.imagePath,
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
|
this.initialScale,
|
||||||
|
this.initialOffset,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -31,119 +29,257 @@ class CropScreen extends StatefulWidget {
|
|||||||
class _CropScreenState extends State<CropScreen> {
|
class _CropScreenState extends State<CropScreen> {
|
||||||
final ImageCropService _cropService = ImageCropService();
|
final ImageCropService _cropService = ImageCropService();
|
||||||
|
|
||||||
|
// États de transformation
|
||||||
|
double _scale = 1.0;
|
||||||
|
double _baseScale = 1.0;
|
||||||
|
Offset _offset = Offset.zero;
|
||||||
|
Offset _startOffset = Offset.zero;
|
||||||
|
Offset _startFocalPoint = Offset.zero;
|
||||||
|
|
||||||
|
// PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés
|
||||||
|
double _rotation = 0.0;
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _imageLoaded = false;
|
bool _imageLoaded = false;
|
||||||
Size? _imageSize;
|
Size? _imageSize;
|
||||||
|
late Size _viewportSize;
|
||||||
// Position et échelle de l'image
|
late double _cropSize;
|
||||||
Offset _offset = Offset.zero;
|
|
||||||
double _scale = 1.0;
|
|
||||||
double _baseScale = 1.0;
|
|
||||||
Offset _startFocalPoint = Offset.zero;
|
|
||||||
Offset _startOffset = Offset.zero;
|
|
||||||
|
|
||||||
// Dimensions calculées
|
|
||||||
double _cropSize = 0;
|
|
||||||
Size _viewportSize = Size.zero;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadImageDimensions();
|
_loadImageInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadImageDimensions() async {
|
Future<void> _loadImageInfo() async {
|
||||||
final file = File(widget.imagePath);
|
final file = File(widget.imagePath);
|
||||||
final bytes = await file.readAsBytes();
|
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||||||
final codec = await ui.instantiateImageCodec(bytes);
|
if (mounted) {
|
||||||
final frame = await codec.getNextFrame();
|
setState(() {
|
||||||
|
_imageSize = Size(
|
||||||
setState(() {
|
decodedImage.width.toDouble(),
|
||||||
_imageSize = Size(
|
decodedImage.height.toDouble(),
|
||||||
frame.image.width.toDouble(),
|
);
|
||||||
frame.image.height.toDouble(),
|
_imageLoaded = true;
|
||||||
);
|
});
|
||||||
_imageLoaded = true;
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: const Color(0xFF101214),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: const Color(0xFF101214),
|
||||||
foregroundColor: Colors.white,
|
elevation: 0,
|
||||||
title: const Text('Recadrer'),
|
centerTitle: true,
|
||||||
|
title: const Text(
|
||||||
|
'Centrage de la cible',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||||
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_isLoading)
|
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.check),
|
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||||
onPressed: _onCropConfirm,
|
tooltip: 'Réinitialiser l\'orientation',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_rotation = 0.0;
|
||||||
|
_scale = 1.0;
|
||||||
|
_offset = Offset.zero;
|
||||||
|
_initializeImagePosition();
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'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),
|
||||||
|
Text(
|
||||||
|
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
||||||
|
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),
|
||||||
|
|
||||||
|
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Rotation de l\'image',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${_rotation.toStringAsFixed(1)}°',
|
||||||
|
style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.rotate_left, color: Colors.white38, size: 20),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _rotation,
|
||||||
|
min: -15.0,
|
||||||
|
max: 15.0,
|
||||||
|
divisions: 300,
|
||||||
|
label: '${_rotation.toStringAsFixed(1)}°',
|
||||||
|
activeColor: const Color(0xFF1A73E8),
|
||||||
|
inactiveColor: Colors.white12,
|
||||||
|
onChanged: (double value) {
|
||||||
|
setState(() {
|
||||||
|
_rotation = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.rotate_right, color: Colors.white38, size: 20),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_rotation = 0.0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Boutons du bas
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.white24),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
child: const Text('Retour', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _onCropConfirm,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF1A73E8),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildInteractiveCrop() {
|
||||||
if (!_imageLoaded || _imageSize == null) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isLoading) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const CircularProgressIndicator(color: Colors.white),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Recadrage en cours...',
|
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.8)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
|
||||||
// Taille du carré de crop (90% de la plus petite dimension)
|
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
|
||||||
_cropSize =
|
// visée = toute la zone visible → aucun bord noir autour du cadre.
|
||||||
math.min(constraints.maxWidth, constraints.maxHeight) * 0.85;
|
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
|
||||||
|
|
||||||
// Calculer l'échelle initiale si pas encore fait
|
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
|
||||||
if (_scale == 1.0 && _offset == Offset.zero) {
|
|
||||||
_initializeImagePosition();
|
_initializeImagePosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onScaleStart: _onScaleStart,
|
onScaleStart: _onScaleStart,
|
||||||
onScaleUpdate: _onScaleUpdate,
|
onScaleUpdate: _onScaleUpdate,
|
||||||
onScaleEnd: _onScaleEnd,
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Image transformée
|
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Center(
|
child: Center(
|
||||||
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)),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Image.file(
|
child: Image.file(
|
||||||
File(widget.imagePath),
|
File(widget.imagePath),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.cover,
|
||||||
width: _viewportSize.width,
|
width: _viewportSize.width,
|
||||||
height: _viewportSize.height,
|
height: _viewportSize.height,
|
||||||
),
|
),
|
||||||
@@ -151,19 +287,58 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Overlay de recadrage
|
// 2. Les lignes vertes de visée prolongées
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: CropOverlay(cropSize: _cropSize, showGrid: true),
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 1.5,
|
||||||
|
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 1.5,
|
||||||
|
height: double.infinity,
|
||||||
|
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Instructions en bas
|
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
|
||||||
Positioned(
|
Positioned.fill(
|
||||||
left: 0,
|
child: IgnorePointer(
|
||||||
right: 0,
|
child: ColorFiltered(
|
||||||
bottom: 24,
|
colorFilter: ColorFilter.mode(
|
||||||
child: _buildInstructions(),
|
const Color(0xFF101214),
|
||||||
|
BlendMode.srcOut,
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: _cropSize,
|
||||||
|
height: _cropSize,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 4. Ton overlay avec les coins blancs
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: CropOverlay(cropSize: _cropSize, showGrid: false),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -175,25 +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;
|
||||||
|
|
||||||
// Calculer la taille de l'image affichée (avec 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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Échelle pour que le plus petit côté de l'image remplisse le carré de crop
|
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
|
||||||
final minDisplayDim = math.min(displayWidth, displayHeight);
|
// Les symboles « − » et « + » de part et d'autre sont purement décoratifs.
|
||||||
_scale = _cropSize / minDisplayDim;
|
Widget _buildDirectionalPad() {
|
||||||
|
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('+'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// S'assurer d'un scale minimum
|
Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) {
|
||||||
if (_scale < 1.0) _scale = 1.0;
|
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) {
|
||||||
@@ -204,138 +425,69 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
|
|
||||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
// Mise à jour du scale
|
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
|
||||||
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
|
|
||||||
|
|
||||||
// Mise à jour de la position
|
|
||||||
final delta = details.focalPoint - _startFocalPoint;
|
final delta = details.focalPoint - _startFocalPoint;
|
||||||
_offset = _startOffset + delta;
|
_offset = _startOffset + delta;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScaleEnd(ScaleEndDetails details) {
|
|
||||||
// Optionnel: contraindre l'image pour qu'elle couvre toujours le carré de crop
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInstructions() {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.touch_app,
|
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Déplacez et zoomez pour cadrer la cible',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onCropConfirm() async {
|
Future<void> _onCropConfirm() async {
|
||||||
if (_imageSize == null) return;
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Calculer la zone de crop en coordonnées normalisées de l'image
|
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
|
||||||
final cropRect = _calculateCropRect();
|
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
|
||||||
|
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
|
||||||
// Recadrer l'image
|
final double displayPerSourcePx = math.max(
|
||||||
final croppedPath = await _cropService.cropToSquare(
|
_viewportSize.width / _imageSize!.width,
|
||||||
widget.imagePath,
|
_viewportSize.height / _imageSize!.height,
|
||||||
cropRect,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// sont remplis en noir → la position choisie est respectée à l'identique.
|
||||||
|
final croppedImagePath = await _cropService.cropViewport(
|
||||||
|
sourcePath: widget.imagePath,
|
||||||
|
offsetDx: _offset.dx,
|
||||||
|
offsetDy: _offset.dy,
|
||||||
|
displayPerSourcePx: displayPerSourcePx,
|
||||||
|
cropSizeDisplay: _cropSize,
|
||||||
|
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
|
||||||
|
// il n'agrandit pas le rendu de sortie.
|
||||||
|
zoomScale: _scale,
|
||||||
|
rotationDegrees: _rotation,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetCenterX = 0.5;
|
||||||
|
const targetCenterY = 0.5;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Naviguer vers l'écran d'analyse avec l'image recadrée
|
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => AnalysisScreen(
|
builder: (_) => AnalysisScreen(
|
||||||
imagePath: croppedPath,
|
imagePath: croppedImagePath,
|
||||||
|
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
|
||||||
|
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
|
||||||
|
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
|
||||||
|
originalImagePath: widget.imagePath,
|
||||||
targetType: widget.targetType,
|
targetType: widget.targetType,
|
||||||
|
initialCenterX: targetCenterX,
|
||||||
|
initialCenterY: targetCenterY,
|
||||||
|
cropScale: 1.0,
|
||||||
|
cropOffset: Offset.zero,
|
||||||
|
cropRotation: 0.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
setState(() {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_isLoading = false;
|
SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
|
||||||
});
|
);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur lors du recadrage: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CropRect _calculateCropRect() {
|
}
|
||||||
if (_imageSize == null) {
|
|
||||||
return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
|
||||||
|
|
||||||
// Calculer la taille de l'image affichée (avec BoxFit.contain)
|
|
||||||
double displayWidth, displayHeight;
|
|
||||||
if (imageAspect > viewportAspect) {
|
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
|
||||||
displayHeight = _viewportSize.height;
|
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Taille de l'image après scale
|
|
||||||
final scaledWidth = displayWidth * _scale;
|
|
||||||
final scaledHeight = displayHeight * _scale;
|
|
||||||
|
|
||||||
// Position du centre de l'image dans le viewport
|
|
||||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
|
||||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
|
||||||
|
|
||||||
// Position du coin supérieur gauche de l'image
|
|
||||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
|
||||||
final imageTop = imageCenterY - scaledHeight / 2;
|
|
||||||
|
|
||||||
// Position du carré de crop (centré dans le viewport)
|
|
||||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
|
||||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
|
||||||
|
|
||||||
// Convertir en coordonnées relatives à l'image affichée
|
|
||||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
|
||||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
|
||||||
final relCropSize = _cropSize / scaledWidth;
|
|
||||||
final relCropSizeY = _cropSize / scaledHeight;
|
|
||||||
|
|
||||||
return CropRect(
|
|
||||||
x: relCropLeft.clamp(0.0, 1.0),
|
|
||||||
y: relCropTop.clamp(0.0, 1.0),
|
|
||||||
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)),
|
|
||||||
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -68,7 +68,7 @@ class _CropOverlayPainter extends CustomPainter {
|
|||||||
_drawGrid(canvas, cropRect);
|
_drawGrid(canvas, cropRect);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dessiner le point central (croix)
|
// Dessiner le point central (croix interne vert fluo)
|
||||||
_drawCenterPoint(canvas, cropRect);
|
_drawCenterPoint(canvas, cropRect);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,39 +165,44 @@ class _CropOverlayPainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _drawCenterPoint(Canvas canvas, Rect rect) {
|
void _drawCenterPoint(Canvas canvas, Rect rect) {
|
||||||
|
// Peinture de la croix interne (Vert Fluo)
|
||||||
final centerPaint = Paint()
|
final centerPaint = Paint()
|
||||||
..color = Colors.white.withValues(alpha: 0.8)
|
..color = const Color(0xFF00FF00)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 2;
|
..strokeWidth = 2.0;
|
||||||
|
|
||||||
const size = 10.0;
|
// Cercle extérieur (Vert Fluo fin)
|
||||||
|
final circlePaint = Paint()
|
||||||
|
..color = const Color(0xFF00FF00).withValues(alpha: 0.9)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2;
|
||||||
|
|
||||||
|
const circleRadius = 10.0; // Augmenté légèrement à 10 pour que la croix respire
|
||||||
final centerX = rect.center.dx;
|
final centerX = rect.center.dx;
|
||||||
final centerY = rect.center.dy;
|
final centerY = rect.center.dy;
|
||||||
|
|
||||||
// Ligne horizontale
|
// 1. Dessiner le cercle de contour
|
||||||
|
canvas.drawCircle(rect.center, circleRadius, circlePaint);
|
||||||
|
|
||||||
|
// 2. Dessiner la croix UNIQUEMENT à l'intérieur du cercle
|
||||||
|
|
||||||
|
// Ligne horizontale (de gauche à droite, reste à l'intérieur)
|
||||||
canvas.drawLine(
|
canvas.drawLine(
|
||||||
Offset(centerX - size, centerY),
|
Offset(centerX - circleRadius, centerY),
|
||||||
Offset(centerX + size, centerY),
|
Offset(centerX + circleRadius, centerY),
|
||||||
centerPaint,
|
centerPaint,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ligne verticale
|
// Ligne verticale (de haut en bas, reste à l'intérieur)
|
||||||
canvas.drawLine(
|
canvas.drawLine(
|
||||||
Offset(centerX, centerY - size),
|
Offset(centerX, centerY - circleRadius),
|
||||||
Offset(centerX, centerY + size),
|
Offset(centerX, centerY + circleRadius),
|
||||||
centerPaint,
|
centerPaint,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Petit cercle central pour précision (optionnel, mais aide à viser)
|
|
||||||
canvas.drawCircle(
|
|
||||||
rect.center,
|
|
||||||
2,
|
|
||||||
Paint()..color = Colors.red.withValues(alpha: 0.6),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
|
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
|
||||||
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;
|
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
595
lib/features/garage/weapon_detail_screen.dart
Normal file
595
lib/features/garage/weapon_detail_screen.dart
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../data/models/weapon.dart';
|
||||||
|
import '../../data/models/maintenance.dart';
|
||||||
|
import '../../data/repositories/session_repository.dart';
|
||||||
|
|
||||||
|
class WeaponDetailScreen extends StatefulWidget {
|
||||||
|
final Weapon weapon;
|
||||||
|
const WeaponDetailScreen({super.key, required this.weapon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeaponDetailScreen> createState() => _WeaponDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||||
|
late Weapon _weapon;
|
||||||
|
List<MaintenanceEntry> _maintenance = [];
|
||||||
|
int _totalRounds = 0;
|
||||||
|
int _sessionCount = 0;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_weapon = widget.weapon;
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final history = await repository.getMaintenanceHistory(_weapon.id);
|
||||||
|
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
||||||
|
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_maintenance = history;
|
||||||
|
_totalRounds = rounds;
|
||||||
|
_sessionCount = sessions;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_weapon.customName ?? _weapon.name),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
onPressed: _showEditWeaponDialog,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildStatsSection(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildInfoCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildOptionsCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildHistorySection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: _showAddMaintenanceDialog,
|
||||||
|
label: const Text('Entretien'),
|
||||||
|
icon: const Icon(Icons.build),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatsSection() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard(
|
||||||
|
'Total Tirs',
|
||||||
|
_totalRounds.toString(),
|
||||||
|
Icons.ads_click,
|
||||||
|
AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard(
|
||||||
|
'Sessions',
|
||||||
|
_sessionCount.toString(),
|
||||||
|
Icons.history,
|
||||||
|
Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const Divider(),
|
||||||
|
_buildInfoRow('Modèle', _weapon.name),
|
||||||
|
_buildInfoRow('Type', _weapon.type.displayName),
|
||||||
|
_buildInfoRow('Calibre', _weapon.caliber),
|
||||||
|
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
|
||||||
|
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Notes:', style: TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
Text(_weapon.notes!, style: const TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOptionsCard() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
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(),
|
||||||
|
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
||||||
|
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
|
||||||
|
_buildInfoRow('Détente', _weapon.trigger ?? 'Standard'),
|
||||||
|
if (_weapon.customName != null)
|
||||||
|
_buildInfoRow('Surnom', _weapon.customName!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(color: Colors.grey)),
|
||||||
|
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistorySection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Historique d\'entretien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_maintenance.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||||
|
child: Center(child: Text('Aucun entretien enregistré', style: TextStyle(color: Colors.grey))),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: _maintenance.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = _maintenance[index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: _getMaintenanceColor(entry.type).withValues(alpha: 0.1),
|
||||||
|
child: Icon(_getMaintenanceIcon(entry.type), color: _getMaintenanceColor(entry.type), size: 20),
|
||||||
|
),
|
||||||
|
title: Text(entry.type.displayName),
|
||||||
|
subtitle: Text(entry.description),
|
||||||
|
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
|
||||||
|
onLongPress: () => _confirmDeleteMaintenance(entry),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getMaintenanceColor(MaintenanceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MaintenanceType.cleaning: return Colors.blue;
|
||||||
|
case MaintenanceType.repair: return Colors.red;
|
||||||
|
case MaintenanceType.inspection: return Colors.green;
|
||||||
|
case MaintenanceType.upgrade: return Colors.purple;
|
||||||
|
case MaintenanceType.other: return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getMaintenanceIcon(MaintenanceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MaintenanceType.cleaning: return Icons.opacity;
|
||||||
|
case MaintenanceType.repair: return Icons.build;
|
||||||
|
case MaintenanceType.inspection: return Icons.visibility;
|
||||||
|
case MaintenanceType.upgrade: return Icons.trending_up;
|
||||||
|
case MaintenanceType.other: return Icons.more_horiz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 caliberController = TextEditingController(text: _weapon.caliber);
|
||||||
|
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
|
||||||
|
final magCapController = TextEditingController(text: _weapon.magazineCapacity.toString());
|
||||||
|
final notesController = TextEditingController(text: _weapon.notes);
|
||||||
|
final opticController = TextEditingController(text: _weapon.optic);
|
||||||
|
final silencerController = TextEditingController(text: _weapon.silencer);
|
||||||
|
final triggerController = TextEditingController(text: _weapon.trigger);
|
||||||
|
final customNameController = TextEditingController(text: _weapon.customName);
|
||||||
|
WeaponType selectedType = _weapon.type;
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: const Text('Modifier l\'arme'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: customNameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
||||||
|
)),
|
||||||
|
DropdownButtonFormField<WeaponType>(
|
||||||
|
initialValue: selectedType,
|
||||||
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
|
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
|
),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: caliberController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Calibre'),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
_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'),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
_autoScrollOnFocus(Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: magCountController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Chargeurs'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: magCapController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Capacité'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: notesController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Notes'),
|
||||||
|
maxLines: 2,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Enregistrer')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
final updatedWeapon = _weapon.copyWith(
|
||||||
|
name: nameController.text,
|
||||||
|
type: selectedType,
|
||||||
|
caliber: caliberController.text,
|
||||||
|
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
||||||
|
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
||||||
|
notes: notesController.text,
|
||||||
|
optic: opticController.text.isEmpty ? null : opticController.text,
|
||||||
|
silencer: silencerController.text.isEmpty ? null : silencerController.text,
|
||||||
|
trigger: triggerController.text.isEmpty ? null : triggerController.text,
|
||||||
|
customName: customNameController.text.isEmpty ? null : customNameController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.updateWeapon(updatedWeapon);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_weapon = updatedWeapon;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// É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();
|
||||||
|
MaintenanceType selectedType = MaintenanceType.cleaning;
|
||||||
|
DateTime selectedDate = DateTime.now();
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: const Text('Ajouter un entretien'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<MaintenanceType>(
|
||||||
|
initialValue: selectedType,
|
||||||
|
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
|
||||||
|
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: descController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
||||||
|
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: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == true && descController.text.isNotEmpty) {
|
||||||
|
await repository.addMaintenanceEntry(
|
||||||
|
weaponId: _weapon.id,
|
||||||
|
type: selectedType,
|
||||||
|
description: descController.text,
|
||||||
|
roundsSinceLast: _totalRounds,
|
||||||
|
date: selectedDate,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Supprimer'),
|
||||||
|
content: const Text('Supprimer cette entrée de l\'historique ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await repository.deleteMaintenanceEntry(entry.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
281
lib/features/garage/weapon_list_screen.dart
Normal file
281
lib/features/garage/weapon_list_screen.dart
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../data/models/weapon.dart';
|
||||||
|
import '../../data/repositories/session_repository.dart';
|
||||||
|
import 'weapon_detail_screen.dart';
|
||||||
|
|
||||||
|
class WeaponListScreen extends StatefulWidget {
|
||||||
|
const WeaponListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeaponListScreen> createState() => _WeaponListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||||
|
List<Weapon> _weapons = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWeapons() async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final weapons = await repository.getWeapons();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_weapons = weapons;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Mon Armurerie'),
|
||||||
|
),
|
||||||
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _weapons.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: _buildWeaponList(),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _showAddWeaponDialog,
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.shield, size: 64, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Aucune arme enregistrée'),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _showAddWeaponDialog,
|
||||||
|
child: const Text('Ajouter ma première arme'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWeaponList() {
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
|
itemCount: _weapons.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final weapon = _weapons[index];
|
||||||
|
final accessories = _accessoryChips(weapon);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)),
|
||||||
|
);
|
||||||
|
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
||||||
|
},
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 caliberController = TextEditingController();
|
||||||
|
final magCountController = TextEditingController(text: '2');
|
||||||
|
final magCapController = TextEditingController(text: '15');
|
||||||
|
WeaponType selectedType = WeaponType.handgun;
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: const Text('Ajouter une arme'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
|
||||||
|
),
|
||||||
|
DropdownButtonFormField<WeaponType>(
|
||||||
|
initialValue: selectedType,
|
||||||
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
|
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: caliberController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: magCountController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Chargeurs'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: magCapController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Capacité'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == true && nameController.text.isNotEmpty) {
|
||||||
|
await repository.addWeapon(
|
||||||
|
name: nameController.text,
|
||||||
|
type: selectedType,
|
||||||
|
caliber: caliberController.text,
|
||||||
|
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
||||||
|
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Supprimer'),
|
||||||
|
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await repository.deleteWeapon(weapon.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
/// Écran d'historique des sessions.
|
|
||||||
///
|
|
||||||
/// Affiche la liste des sessions passées avec filtrage par type de cible.
|
|
||||||
/// Inclut un graphique d'évolution des scores (10 dernières sessions)
|
|
||||||
/// et permet la navigation vers le détail de chaque session.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -18,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();
|
||||||
@@ -29,24 +28,71 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
TargetType? _filterType;
|
TargetType? _filterType;
|
||||||
|
|
||||||
|
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
|
||||||
|
DateTimeRange? _selectedDateRange;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_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;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final sessions = await repository.getAllSessions(
|
final sessions = await repository.getAllSessions();
|
||||||
targetType: _filterType,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
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 ---
|
||||||
|
if (_selectedDateRange != null) {
|
||||||
|
_sessions = _sessions.where((s) {
|
||||||
|
// On compare uniquement les dates (sans les heures) pour éviter les bugs
|
||||||
|
final sessionDate = DateTime(
|
||||||
|
s.createdAt.year,
|
||||||
|
s.createdAt.month,
|
||||||
|
s.createdAt.day,
|
||||||
|
);
|
||||||
|
final startDate = DateTime(
|
||||||
|
_selectedDateRange!.start.year,
|
||||||
|
_selectedDateRange!.start.month,
|
||||||
|
_selectedDateRange!.start.day,
|
||||||
|
);
|
||||||
|
final endDate = DateTime(
|
||||||
|
_selectedDateRange!.end.year,
|
||||||
|
_selectedDateRange!.end.month,
|
||||||
|
_selectedDateRange!.end.day,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sessionDate.isAtSameMomentAs(startDate) ||
|
||||||
|
sessionDate.isAtSameMomentAs(endDate) ||
|
||||||
|
(sessionDate.isAfter(startDate) &&
|
||||||
|
sessionDate.isBefore(endDate));
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,7 +101,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Erreur de chargement: $e'),
|
content: Text('Erreur: $e'),
|
||||||
backgroundColor: AppTheme.errorColor,
|
backgroundColor: AppTheme.errorColor,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -63,63 +109,140 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MODIFICATION : Fonction DateRangePicker ---
|
||||||
|
Future<void> _pickDateRange() async {
|
||||||
|
final DateTimeRange? picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
initialDateRange: _selectedDateRange,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: ThemeData.light().copyWith(
|
||||||
|
colorScheme: ColorScheme.light(
|
||||||
|
primary: AppTheme.primaryColor, // En-tête et sélection
|
||||||
|
onPrimary: Colors.white, // Texte sur en-tête/sélection
|
||||||
|
surface: Colors.white, // Fond du calendrier
|
||||||
|
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
|
||||||
|
secondary: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _selectedDateRange = picked);
|
||||||
|
_loadSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
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
|
||||||
tooltip: 'Filtrer',
|
// (Flutter l'interprète comme une annulation du menu).
|
||||||
onSelected: (type) {
|
PopupMenuButton<String>(
|
||||||
setState(() => _filterType = type);
|
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(
|
const PopupMenuItem(value: 'all', child: Text('Tous')),
|
||||||
value: null,
|
...TargetType.values.map(
|
||||||
child: Text('Tous'),
|
(type) => PopupMenuItem(
|
||||||
|
value: type.name,
|
||||||
|
child: Text(type.displayName),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
...TargetType.values.map((type) => PopupMenuItem(
|
|
||||||
value: type,
|
|
||||||
child: Text(type.displayName),
|
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: Column(
|
||||||
? const Center(child: CircularProgressIndicator())
|
children: [
|
||||||
: _sessions.isEmpty
|
Expanded(
|
||||||
? _buildEmptyState()
|
child: _isLoading
|
||||||
: _buildContent(),
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _sessions.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: _buildContent(),
|
||||||
|
),
|
||||||
|
_buildBottomFilterBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomFilterBar() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black26,
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _pickDateRange,
|
||||||
|
icon: const Icon(Icons.date_range, size: 18),
|
||||||
|
label: Text(
|
||||||
|
_selectedDateRange == null
|
||||||
|
? 'Choisir une période'
|
||||||
|
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_selectedDateRange != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: AppTheme.errorColor),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _selectedDateRange = null);
|
||||||
|
_loadSessions();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
Widget _buildEmptyState() {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
const Text('Aucune session sur cette période'),
|
||||||
const SizedBox(height: 16),
|
],
|
||||||
Text(
|
|
||||||
'Aucune session',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_filterType != null
|
|
||||||
? 'Aucune session de type ${_filterType!.displayName}'
|
|
||||||
: 'Commencez par analyser une cible',
|
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,49 +252,27 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
onRefresh: _loadSessions,
|
onRefresh: _loadSessions,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Chart section
|
if (_sessions.length >= 2 && _selectedDateRange == null)
|
||||||
if (_sessions.length >= 2)
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: HistoryChart(sessions: _sessions),
|
child: HistoryChart(sessions: _sessions),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Filter indicator
|
|
||||||
if (_filterType != null)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppConstants.defaultPadding),
|
|
||||||
child: Chip(
|
|
||||||
label: Text('Filtre: ${_filterType!.displayName}'),
|
|
||||||
deleteIcon: const Icon(Icons.close, size: 18),
|
|
||||||
onDeleted: () {
|
|
||||||
setState(() => _filterType = null);
|
|
||||||
_loadSessions();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Sessions list
|
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
(context, index) {
|
final session = _sessions[index];
|
||||||
final session = _sessions[index];
|
return Padding(
|
||||||
return Padding(
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
child: SessionListItem(
|
||||||
child: SessionListItem(
|
session: session,
|
||||||
session: session,
|
onTap: () => _openSessionDetail(session),
|
||||||
onTap: () => _openSessionDetail(session),
|
onDelete: () => _deleteSession(session),
|
||||||
onDelete: () => _deleteSession(session),
|
),
|
||||||
),
|
);
|
||||||
);
|
}, childCount: _sessions.length),
|
||||||
},
|
|
||||||
childCount: _sessions.length,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -182,11 +283,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
void _openSessionDetail(Session session) async {
|
void _openSessionDetail(Session session) async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(builder: (_) => SessionDetailScreen(session: session)),
|
||||||
builder: (_) => SessionDetailScreen(session: session),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
_loadSessions(); // Refresh in case session was deleted
|
_loadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteSession(Session session) async {
|
Future<void> _deleteSession(Session session) async {
|
||||||
@@ -204,7 +303,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
|
child: const Text(
|
||||||
|
'Supprimer',
|
||||||
|
style: TextStyle(color: AppTheme.errorColor),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -215,20 +317,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.deleteSession(session.id);
|
await repository.deleteSession(session.id);
|
||||||
_loadSessions();
|
_loadSessions();
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Session supprimee')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
debugPrint('Erreur suppression: $e');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
/// Écran de détail d'une session.
|
|
||||||
///
|
|
||||||
/// Affiche la visualisation complète d'une session sauvegardée :
|
|
||||||
/// image de la cible avec overlay des impacts, scores recalculés,
|
|
||||||
/// statistiques de groupement et lien vers les statistiques détaillées.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -12,6 +5,8 @@ import 'package:intl/intl.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/session.dart';
|
import '../../data/models/session.dart';
|
||||||
|
import '../../data/models/target_analysis.dart';
|
||||||
|
import '../../data/models/target_type.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.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';
|
||||||
@@ -20,7 +15,7 @@ import '../analysis/widgets/score_card.dart';
|
|||||||
import '../analysis/widgets/grouping_stats.dart';
|
import '../analysis/widgets/grouping_stats.dart';
|
||||||
import '../statistics/statistics_screen.dart';
|
import '../statistics/statistics_screen.dart';
|
||||||
|
|
||||||
class SessionDetailScreen extends StatelessWidget {
|
class SessionDetailScreen extends StatefulWidget {
|
||||||
final Session session;
|
final Session session;
|
||||||
|
|
||||||
const SessionDetailScreen({
|
const SessionDetailScreen({
|
||||||
@@ -28,33 +23,40 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
required this.session,
|
required this.session,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionDetailScreenState extends State<SessionDetailScreen> {
|
||||||
|
int _currentTargetIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scoreCalculator = context.read<ScoreCalculatorService>();
|
final scoreCalculator = context.read<ScoreCalculatorService>();
|
||||||
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
|
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
|
||||||
|
|
||||||
|
final currentAnalysis = widget.session.analyses[_currentTargetIndex];
|
||||||
|
|
||||||
final scoreResult = scoreCalculator.calculateScores(
|
final scoreResult = scoreCalculator.calculateScores(
|
||||||
shots: session.shots,
|
shots: currentAnalysis.shots,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
targetRadius: session.targetRadius ?? 0.4,
|
targetRadius: currentAnalysis.targetRadius ?? 0.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots);
|
final groupingResult = groupingAnalyzer.analyzeGrouping(currentAnalysis.shots);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(widget.session.weapon),
|
||||||
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.analytics),
|
icon: const Icon(Icons.analytics),
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => StatisticsScreen(singleSession: session),
|
builder: (_) => StatisticsScreen(singleSession: widget.session),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tooltip: 'Statistiques',
|
tooltip: 'Statistiques',
|
||||||
@@ -70,15 +72,22 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// Weapon and session overview
|
||||||
|
_buildSessionHeader(context),
|
||||||
|
|
||||||
|
// Target selector if multiple targets
|
||||||
|
if (widget.session.targetCount > 1)
|
||||||
|
_buildTargetSelector(),
|
||||||
|
|
||||||
// Target image with overlay
|
// Target image with overlay
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (File(session.imagePath).existsSync())
|
if (File(currentAnalysis.imagePath).existsSync())
|
||||||
Image.file(
|
Image.file(
|
||||||
File(session.imagePath),
|
File(currentAnalysis.imagePath),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -89,14 +98,14 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TargetOverlay(
|
TargetOverlay(
|
||||||
shots: session.shots,
|
shots: currentAnalysis.shots,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
targetRadius: session.targetRadius ?? 0.4,
|
targetRadius: currentAnalysis.targetRadius ?? 0.4,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
groupingCenterX: session.groupingCenterX,
|
groupingCenterX: currentAnalysis.groupingCenterX,
|
||||||
groupingCenterY: session.groupingCenterY,
|
groupingCenterY: currentAnalysis.groupingCenterY,
|
||||||
groupingDiameter: session.groupingDiameter,
|
groupingDiameter: currentAnalysis.groupingDiameter,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -107,31 +116,37 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Session info
|
// Analysis Info
|
||||||
_buildSessionInfo(context),
|
_buildAnalysisInfo(context, currentAnalysis),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Score card
|
// Score card for current target
|
||||||
ScoreCard(
|
ScoreCard(
|
||||||
totalScore: session.totalScore,
|
totalScore: currentAnalysis.totalScore,
|
||||||
shotCount: session.shotCount,
|
shotCount: currentAnalysis.shotCount,
|
||||||
scoreResult: scoreResult,
|
scoreResult: scoreResult,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Grouping stats
|
// Grouping stats for current target
|
||||||
if (session.shotCount > 1)
|
if (currentAnalysis.shotCount > 1)
|
||||||
GroupingStats(
|
GroupingStats(
|
||||||
groupingResult: groupingResult,
|
groupingResult: groupingResult,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Notes
|
// Notes for current target
|
||||||
if (session.notes != null && session.notes!.isNotEmpty) ...[
|
if (currentAnalysis.notes != null && currentAnalysis.notes!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildNotesCard(context),
|
_buildNotesCard(context, currentAnalysis.notes!),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Session notes
|
||||||
|
if (widget.session.notes != null && widget.session.notes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildNotesCard(context, widget.session.notes!, title: 'Notes de session'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -142,16 +157,83 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSessionInfo(BuildContext context) {
|
Widget _buildSessionHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat('EEEE dd MMMM yyyy', 'fr_FR').format(widget.session.createdAt),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
DateFormat('HH:mm').format(widget.session.createdAt),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Score Total: ${widget.session.totalScore}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryColor),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${widget.session.totalShots} tirs au total',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTargetSelector() {
|
||||||
|
return Container(
|
||||||
|
height: 50,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: widget.session.targetCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final isSelected = _currentTargetIndex == index;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text('Cible ${index + 1}'),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setState(() => _currentTargetIndex = index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnalysisInfo(BuildContext context, TargetAnalysis analysis) {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
session.targetType == session.targetType
|
analysis.targetType == TargetType.concentric ? Icons.track_changes : Icons.person,
|
||||||
? Icons.track_changes
|
|
||||||
: Icons.person,
|
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -160,14 +242,11 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
session.targetType.displayName,
|
analysis.targetType.displayName,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR')
|
'Cible ${_currentTargetIndex + 1} sur ${widget.session.targetCount}',
|
||||||
.format(session.createdAt),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -179,7 +258,7 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNotesCard(BuildContext context) {
|
Widget _buildNotesCard(BuildContext context, String notes, {String title = 'Notes'}) {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
@@ -188,18 +267,16 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.notes, color: AppTheme.primaryColor),
|
const Icon(Icons.notes, color: AppTheme.primaryColor, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Notes',
|
title,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Text(session.notes!),
|
Text(notes),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -211,7 +288,7 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Supprimer'),
|
title: const Text('Supprimer'),
|
||||||
content: const Text('Voulez-vous vraiment supprimer cette session?'),
|
content: const Text('Voulez-vous vraiment supprimer cette session entière ?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
@@ -231,20 +308,17 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
if (confirmed == true && context.mounted) {
|
if (confirmed == true && context.mounted) {
|
||||||
try {
|
try {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.deleteSession(session.id);
|
await repository.deleteSession(widget.session.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Session supprimee')),
|
const SnackBar(content: Text('Session supprimée')),
|
||||||
);
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
|
||||||
content: Text('Erreur: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class HistoryChart extends StatelessWidget {
|
|||||||
return touchedSpots.map((spot) {
|
return touchedSpots.map((spot) {
|
||||||
final session = displaySessions[spot.x.toInt()];
|
final session = displaySessions[spot.x.toInt()];
|
||||||
return LineTooltipItem(
|
return LineTooltipItem(
|
||||||
'Score: ${session.totalScore}\n${session.shotCount} tirs',
|
'Score: ${session.totalScore}\n${session.totalShots} tirs',
|
||||||
const TextStyle(
|
const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
/// Item de liste représentant une session.
|
|
||||||
///
|
|
||||||
/// Affiche les informations clés d'une session (date, type de cible,
|
|
||||||
/// score, nombre de tirs) avec miniature de l'image et actions de suppression.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../data/models/session.dart';
|
import '../../../data/models/session.dart';
|
||||||
import '../../../data/models/target_type.dart';
|
|
||||||
|
|
||||||
class SessionListItem extends StatelessWidget {
|
class SessionListItem extends StatelessWidget {
|
||||||
final Session session;
|
final Session session;
|
||||||
@@ -33,7 +26,7 @@ class SessionListItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Thumbnail
|
// Thumbnail (from first target)
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@@ -51,14 +44,14 @@ class SessionListItem extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
_getTargetIcon(),
|
Icons.shield,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
session.targetType.displayName,
|
session.weapon,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -70,18 +63,19 @@ class SessionListItem extends StatelessWidget {
|
|||||||
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
if (session.notes != null && session.notes!.isNotEmpty) ...[
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 2),
|
Row(
|
||||||
Text(
|
children: [
|
||||||
session.notes!,
|
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
const SizedBox(width: 4),
|
||||||
color: Colors.grey[600],
|
Text(
|
||||||
fontStyle: FontStyle.italic,
|
'${session.targetCount} cible(s) • ${session.distance}m',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -98,7 +92,7 @@ class SessionListItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${session.shotCount} tirs',
|
'${session.totalShots} tirs',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -120,7 +114,9 @@ class SessionListItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildThumbnail() {
|
Widget _buildThumbnail() {
|
||||||
final file = File(session.imagePath);
|
if (session.analyses.isEmpty) return _buildPlaceholder();
|
||||||
|
|
||||||
|
final file = File(session.analyses.first.imagePath);
|
||||||
|
|
||||||
if (file.existsSync()) {
|
if (file.existsSync()) {
|
||||||
return Image.file(
|
return Image.file(
|
||||||
@@ -137,18 +133,9 @@ class SessionListItem extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
color: Colors.grey[200],
|
color: Colors.grey[200],
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_getTargetIcon(),
|
Icons.track_changes,
|
||||||
color: Colors.grey[400],
|
color: Colors.grey[400],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getTargetIcon() {
|
|
||||||
switch (session.targetType) {
|
|
||||||
case TargetType.concentric:
|
|
||||||
return Icons.track_changes;
|
|
||||||
case TargetType.silhouette:
|
|
||||||
return Icons.person;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
/// Écran d'accueil - Dashboard principal de l'application.
|
|
||||||
///
|
|
||||||
/// Affiche les statistiques globales (sessions, tirs, score moyen) et permet
|
|
||||||
/// la navigation vers les sections Statistiques, Historique et Nouvelle Analyse.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
@@ -12,7 +6,11 @@ import '../../data/repositories/session_repository.dart';
|
|||||||
import '../capture/capture_screen.dart';
|
import '../capture/capture_screen.dart';
|
||||||
import '../history/history_screen.dart';
|
import '../history/history_screen.dart';
|
||||||
import '../statistics/statistics_screen.dart';
|
import '../statistics/statistics_screen.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import '../../data/models/session.dart';
|
||||||
import '../settings/settings_screen.dart';
|
import '../settings/settings_screen.dart';
|
||||||
|
import '../session/session_setup_screen.dart';
|
||||||
|
import '../session/session_provider.dart';
|
||||||
import 'widgets/stats_card.dart';
|
import 'widgets/stats_card.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
@@ -24,20 +22,55 @@ class HomeScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
Map<String, dynamic>? _stats;
|
Map<String, dynamic>? _stats;
|
||||||
|
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();
|
||||||
|
final sessions = await repository.getAllSessions();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_stats = stats;
|
_stats = stats;
|
||||||
|
_recentSessions = sessions;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,18 +80,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
leading: const Center(
|
||||||
|
child: Text(
|
||||||
|
'v1.0.2',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.white70),
|
||||||
|
),
|
||||||
|
),
|
||||||
title: const Text('Bully'),
|
title: const Text('Bully'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.analytics),
|
|
||||||
onPressed: () => _navigateToStatistics(context),
|
|
||||||
tooltip: 'Statistiques',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.history),
|
|
||||||
onPressed: () => _navigateToHistory(context),
|
|
||||||
tooltip: 'Historique',
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
onPressed: () => _navigateToSettings(context),
|
onPressed: () => _navigateToSettings(context),
|
||||||
@@ -74,15 +103,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// App logo/header
|
|
||||||
_buildHeader(),
|
_buildHeader(),
|
||||||
const SizedBox(height: AppConstants.largePadding),
|
const SizedBox(height: AppConstants.largePadding),
|
||||||
|
|
||||||
// Main action button
|
|
||||||
_buildMainActionButton(context),
|
_buildMainActionButton(context),
|
||||||
const SizedBox(height: AppConstants.largePadding),
|
const SizedBox(height: AppConstants.largePadding),
|
||||||
|
|
||||||
// Statistics section
|
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const Center(child: CircularProgressIndicator())
|
const Center(child: CircularProgressIndicator())
|
||||||
else if (_stats != null)
|
else if (_stats != null)
|
||||||
@@ -112,16 +136,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Analyse de Cibles',
|
'Analyse de Cibles',
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Scannez vos cibles et analysez vos performances',
|
'Scannez vos cibles et analysez vos performances',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(
|
||||||
color: AppTheme.textSecondary,
|
context,
|
||||||
),
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -129,21 +153,103 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMainActionButton(BuildContext context) {
|
Widget _buildMainActionButton(BuildContext context) {
|
||||||
return ElevatedButton.icon(
|
final sessionProvider = context.watch<SessionProvider>();
|
||||||
onPressed: () => _navigateToCapture(context),
|
final isSessionActive = sessionProvider.isSessionActive;
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.primaryColor,
|
// Si aucune session n'est active, on affiche juste le bouton de départ habituel
|
||||||
foregroundColor: Colors.white,
|
if (!isSessionActive) {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
return ElevatedButton.icon(
|
||||||
shape: RoundedRectangleBorder(
|
onPressed: () => _navigateToSessionSetup(context),
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
icon: const Icon(Icons.add_circle_outline, size: 28),
|
||||||
icon: const Icon(Icons.add_a_photo, size: 28),
|
label: const Text(
|
||||||
label: const Text(
|
'Nouvelle session',
|
||||||
'Nouvelle Analyse',
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MODIFICATION : Alignement sur une seule ligne (80% / 20%) avec une croix pour l'annulation
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// 80% de largeur pour continuer la session
|
||||||
|
Expanded(
|
||||||
|
flex: 8,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => _navigateToSessionSetup(context),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.secondaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.play_circle_outline, size: 28),
|
||||||
|
label: const Text(
|
||||||
|
'Continuer la session',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// 20% de largeur pour terminer/annuler la session (Bouton Croix)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Boite de dialogue de confirmation pour éviter les erreurs
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Terminer la session ?'),
|
||||||
|
content: const Text('Êtes-vous sûr de vouloir clôturer la session en cours ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('ANNULER'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
// Clôture propre de la session dans le State global
|
||||||
|
context.read<SessionProvider>().endSession();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Session terminée avec succès !'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.redAccent,
|
||||||
|
side: const BorderSide(color: Colors.redAccent, width: 1.5),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 20), // Même hauteur que le bouton continuer
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.close, size: 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,14 +259,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Statistiques',
|
'Statistiques',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// --- BOUTON SESSIONS (Redirige vers Statistiques) ---
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _navigateToStatistics(context),
|
onTap: () => _navigateToStatistics(context),
|
||||||
@@ -174,7 +279,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Ce bouton reste statique (ou tu peux ajouter une action)
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StatsCard(
|
child: StatsCard(
|
||||||
icon: Icons.gps_fixed,
|
icon: Icons.gps_fixed,
|
||||||
@@ -185,10 +289,40 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: Container(
|
||||||
|
height: 150,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: _recentSessions.length < 2
|
||||||
|
? const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.show_chart, size: 40, color: Colors.grey),
|
||||||
|
Text(
|
||||||
|
'Pas assez de données',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _buildHomeTrendChart(),
|
||||||
|
),
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// --- BOUTON SCORE MOYEN (Redirige vers Historique) ---
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _navigateToHistory(context),
|
onTap: () => _navigateToHistory(context),
|
||||||
@@ -202,7 +336,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Ce bouton reste statique
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StatsCard(
|
child: StatsCard(
|
||||||
icon: Icons.emoji_events,
|
icon: Icons.emoji_events,
|
||||||
@@ -217,12 +350,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToCapture(BuildContext context) async {
|
void _navigateToSessionSetup(BuildContext context) async {
|
||||||
await Navigator.push(
|
final sessionProvider = context.read<SessionProvider>();
|
||||||
context,
|
if (sessionProvider.isSessionActive) {
|
||||||
MaterialPageRoute(builder: (_) => const CaptureScreen()),
|
await Navigator.push(
|
||||||
);
|
context,
|
||||||
// Refresh stats when returning
|
MaterialPageRoute(builder: (_) => const CaptureScreen()),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const SessionSetupScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +371,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const HistoryScreen()),
|
MaterialPageRoute(builder: (_) => const HistoryScreen()),
|
||||||
);
|
);
|
||||||
// Refresh stats when returning
|
|
||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,14 +379,107 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const StatisticsScreen()),
|
MaterialPageRoute(builder: (_) => const StatisticsScreen()),
|
||||||
);
|
);
|
||||||
// Refresh stats when returning
|
|
||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToSettings(BuildContext context) {
|
Widget _buildHomeTrendChart() {
|
||||||
Navigator.push(
|
final sorted = List<Session>.from(_recentSessions)
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
final display =
|
||||||
|
sorted.length > 7 ? sorted.sublist(sorted.length - 7) : sorted;
|
||||||
|
|
||||||
|
return LineChart(
|
||||||
|
LineChartData(
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
getDrawingHorizontalLine: (value) => FlLine(
|
||||||
|
color: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
strokeWidth: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 22,
|
||||||
|
interval: 1,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
final index = value.toInt();
|
||||||
|
if (index < 0 || index >= display.length) return const SizedBox();
|
||||||
|
final date = display[index].createdAt;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
'${date.day}/${date.month}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: Colors.grey.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 28,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return Text(
|
||||||
|
value.toInt().toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: Colors.grey.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(
|
||||||
|
show: true,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
|
||||||
|
left: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: display.asMap().entries.map((e) {
|
||||||
|
return FlSpot(e.key.toDouble(), e.value.averageScore);
|
||||||
|
}).toList(),
|
||||||
|
isCurved: true,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: FlDotData(
|
||||||
|
show: true,
|
||||||
|
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
|
||||||
|
radius: 3,
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColor: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToSettings(BuildContext context) async {
|
||||||
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||||
);
|
);
|
||||||
|
_loadStats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
52
lib/features/session/session_provider.dart
Normal file
52
lib/features/session/session_provider.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../data/models/target_analysis.dart';
|
||||||
|
|
||||||
|
class SessionProvider extends ChangeNotifier {
|
||||||
|
DateTime? _sessionDate;
|
||||||
|
DateTime? get sessionDate => _sessionDate;
|
||||||
|
String? _currentWeaponName;
|
||||||
|
String? _currentWeaponId;
|
||||||
|
int _shotsPerTarget = 5;
|
||||||
|
int _distance = 25;
|
||||||
|
String? _activeSessionId;
|
||||||
|
final List<TargetAnalysis> _currentAnalyses = [];
|
||||||
|
bool _isSessionActive = false;
|
||||||
|
|
||||||
|
String? get currentWeapon => _currentWeaponName;
|
||||||
|
String? get currentWeaponId => _currentWeaponId;
|
||||||
|
int get shotsPerTarget => _shotsPerTarget;
|
||||||
|
int get distance => _distance;
|
||||||
|
bool get isSessionActive => _isSessionActive;
|
||||||
|
String? get activeSessionId => _activeSessionId;
|
||||||
|
List<TargetAnalysis> get currentAnalyses => List.unmodifiable(_currentAnalyses);
|
||||||
|
|
||||||
|
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
|
||||||
|
int get targetCount => _currentAnalyses.length;
|
||||||
|
|
||||||
|
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) {
|
||||||
|
_currentWeaponName = weaponName;
|
||||||
|
_currentWeaponId = weaponId;
|
||||||
|
_shotsPerTarget = shots;
|
||||||
|
_distance = distance;
|
||||||
|
_sessionDate = date ?? DateTime.now();
|
||||||
|
_activeSessionId = sessionId;
|
||||||
|
_currentAnalyses.clear();
|
||||||
|
_isSessionActive = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addAnalysis(TargetAnalysis analysis) {
|
||||||
|
_currentAnalyses.add(analysis);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void endSession() {
|
||||||
|
_isSessionActive = false;
|
||||||
|
_activeSessionId = null;
|
||||||
|
_currentWeaponId = null;
|
||||||
|
_currentWeaponName = null;
|
||||||
|
_currentAnalyses.clear();
|
||||||
|
_distance = 25;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
326
lib/features/session/session_setup_screen.dart
Normal file
326
lib/features/session/session_setup_screen.dart
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../data/models/weapon.dart';
|
||||||
|
import '../../data/repositories/session_repository.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
import '../capture/capture_screen.dart';
|
||||||
|
import '../garage/weapon_list_screen.dart';
|
||||||
|
|
||||||
|
class SessionSetupScreen extends StatefulWidget {
|
||||||
|
const SessionSetupScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SessionSetupScreen> createState() => _SessionSetupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
int _shotsPerTarget = 5;
|
||||||
|
int _distance = 25;
|
||||||
|
List<Weapon> _availableWeapons = [];
|
||||||
|
Weapon? _selectedWeapon;
|
||||||
|
bool _isLoadingWeapons = true;
|
||||||
|
|
||||||
|
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
|
||||||
|
DateTime _selectedDate = DateTime.now();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWeapons() async {
|
||||||
|
setState(() => _isLoadingWeapons = true);
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final weapons = await repository.getWeapons();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_availableWeapons = weapons;
|
||||||
|
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
|
||||||
|
_selectedWeapon = _availableWeapons.first;
|
||||||
|
_updateSettingsForWeapon(_selectedWeapon!);
|
||||||
|
}
|
||||||
|
_isLoadingWeapons = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSettingsForWeapon(Weapon weapon) {
|
||||||
|
_shotsPerTarget = weapon.magazineCapacity;
|
||||||
|
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
|
||||||
|
Future<void> _pickDate() async {
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _selectedDate,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
locale: const Locale('fr', 'FR'), // Force le calendrier en français
|
||||||
|
);
|
||||||
|
if (picked != null && picked != _selectedDate) {
|
||||||
|
setState(() {
|
||||||
|
// On garde aussi l'heure actuelle lors du changement de date
|
||||||
|
final now = DateTime.now();
|
||||||
|
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startSession() {
|
||||||
|
if (_formKey.currentState!.validate() && _selectedWeapon != null) {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final sessionProvider = context.read<SessionProvider>();
|
||||||
|
|
||||||
|
final sessionId = repository.generateId();
|
||||||
|
|
||||||
|
// PASSE-PARTOUT : On envoie la session au provider.
|
||||||
|
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
|
||||||
|
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
|
||||||
|
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
|
||||||
|
sessionProvider.startSession(
|
||||||
|
_selectedWeapon!.displayName,
|
||||||
|
_shotsPerTarget,
|
||||||
|
sessionId,
|
||||||
|
weaponId: _selectedWeapon!.id,
|
||||||
|
distance: _distance,
|
||||||
|
date: _selectedDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const CaptureScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Petit formatage sympa en français (ex: "27 mai 2026")
|
||||||
|
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Configuration de la session'),
|
||||||
|
),
|
||||||
|
body: _isLoadingWeapons
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Informations générales',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
|
||||||
|
if (_availableWeapons.isEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.errorColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Ton armurerie est vide.',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
|
||||||
|
).then((_) {
|
||||||
|
_loadWeapons();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('ALLER À MON ARMURERIE'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<Weapon>(
|
||||||
|
initialValue: _selectedWeapon,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Sélectionner une arme',
|
||||||
|
prefixIcon: const Icon(Icons.shield),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: _availableWeapons.map((w) => DropdownMenuItem(
|
||||||
|
value: w,
|
||||||
|
child: Text(w.displayName),
|
||||||
|
)).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedWeapon = value;
|
||||||
|
_updateSettingsForWeapon(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// NOUVEAU CHAMP : Sélecteur de date interactif
|
||||||
|
InkWell(
|
||||||
|
onTap: _availableWeapons.isEmpty ? null : _pickDate,
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: TextFormField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Date de la session',
|
||||||
|
prefixIcon: const Icon(Icons.calendar_today),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
controller: TextEditingController(text: formattedDate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Distance selector
|
||||||
|
const Text(
|
||||||
|
'Distance de tir (mètres)',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _distance.toDouble(),
|
||||||
|
min: 5,
|
||||||
|
max: 300,
|
||||||
|
divisions: 59,
|
||||||
|
label: '${_distance}m',
|
||||||
|
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
||||||
|
setState(() {
|
||||||
|
_distance = value.round();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 70,
|
||||||
|
height: 50,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.secondaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${_distance}m',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.secondaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Shots per target
|
||||||
|
const Text(
|
||||||
|
'Nombre de balles pour la cible actuelle',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _shotsPerTarget.toDouble(),
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
divisions: 49,
|
||||||
|
label: '$_shotsPerTarget',
|
||||||
|
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
||||||
|
setState(() {
|
||||||
|
_shotsPerTarget = value.round();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$_shotsPerTarget',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
|
||||||
|
? null
|
||||||
|
: _startSession,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.play_arrow),
|
||||||
|
label: const Text(
|
||||||
|
'DÉMARRER LA SESSION',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../core/theme/theme_provider.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
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 '../../services/wallet_identity_service.dart';
|
import '../../services/wallet_identity_service.dart';
|
||||||
|
import '../garage/weapon_list_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -64,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +84,47 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showThemeDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Apparence'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto),
|
||||||
|
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode),
|
||||||
|
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) {
|
||||||
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
|
final isSelected = themeProvider.themeMode == mode;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
|
||||||
|
title: Text(label, style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isSelected ? AppTheme.primaryColor : null,
|
||||||
|
)),
|
||||||
|
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
|
||||||
|
onTap: () {
|
||||||
|
themeProvider.setThemeMode(mode);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showIdentityDialog() {
|
void _showIdentityDialog() {
|
||||||
if (_identityPhrase == null) return;
|
if (_identityPhrase == null) return;
|
||||||
|
|
||||||
@@ -201,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(
|
||||||
@@ -218,14 +335,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
|
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
|
||||||
onTap: _showIdentityDialog,
|
onTap: _showIdentityDialog,
|
||||||
),
|
),
|
||||||
|
Consumer<ThemeProvider>(
|
||||||
|
builder: (context, themeProvider, child) {
|
||||||
|
return _buildSettingsTile(
|
||||||
|
context: context,
|
||||||
|
icon: Icons.color_lens_outlined,
|
||||||
|
title: 'Apparence',
|
||||||
|
subtitle: themeProvider.themeModeName,
|
||||||
|
onTap: _showThemeDialog,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSectionHeader('Gestion'),
|
||||||
_buildSettingsTile(
|
_buildSettingsTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.color_lens_outlined,
|
icon: Icons.shield_outlined,
|
||||||
title: 'Apparence',
|
title: 'Mon Armurerie',
|
||||||
subtitle: 'Thème clair/sombre (à venir)',
|
subtitle: 'Gérer mes armes et équipements',
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await Navigator.push(
|
||||||
const SnackBar(content: Text('Fonctionnalité en développement')),
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -242,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),
|
||||||
@@ -286,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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,11 @@ import 'package:intl/date_symbol_data_local.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
import 'app.dart';
|
import 'app.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';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -30,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(),
|
||||||
),
|
),
|
||||||
@@ -45,6 +37,8 @@ void main() async {
|
|||||||
create: (_) => GroupingAnalyzerService(),
|
create: (_) => GroupingAnalyzerService(),
|
||||||
),
|
),
|
||||||
Provider<SessionRepository>(create: (_) => SessionRepository()),
|
Provider<SessionRepository>(create: (_) => SessionRepository()),
|
||||||
|
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
|
||||||
|
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
|
||||||
],
|
],
|
||||||
child: const BullyApp(),
|
child: const BullyApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
89
lib/main_navigation_holder.dart
Normal file
89
lib/main_navigation_holder.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'features/home/home_screen.dart';
|
||||||
|
import 'features/history/history_screen.dart';
|
||||||
|
import 'features/statistics/statistics_screen.dart';
|
||||||
|
import 'features/garage/weapon_list_screen.dart';
|
||||||
|
import 'core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
class MainNavigationHolder extends StatefulWidget {
|
||||||
|
const MainNavigationHolder({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainNavigationHolder> createState() => _MainNavigationHolderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
// Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
|
||||||
|
// rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
|
||||||
|
// rafraîchissent pas seuls).
|
||||||
|
int _statsTick = 0;
|
||||||
|
int _historyTick = 0;
|
||||||
|
|
||||||
|
void _onItemTapped(int index) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIndex = index;
|
||||||
|
if (index == 1) _historyTick++;
|
||||||
|
if (index == 2) _statsTick++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screens = [
|
||||||
|
const HomeScreen(),
|
||||||
|
HistoryScreen(refreshTick: _historyTick),
|
||||||
|
StatisticsScreen(refreshTick: _statsTick),
|
||||||
|
const WeaponListScreen(),
|
||||||
|
];
|
||||||
|
return Scaffold(
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _selectedIndex,
|
||||||
|
children: screens,
|
||||||
|
),
|
||||||
|
bottomNavigationBar: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: BottomNavigationBar(
|
||||||
|
currentIndex: _selectedIndex,
|
||||||
|
onTap: _onItemTapped,
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
backgroundColor: Theme.of(context).cardColor,
|
||||||
|
selectedItemColor: AppTheme.primaryColor,
|
||||||
|
unselectedItemColor: Colors.grey,
|
||||||
|
showUnselectedLabels: true,
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.home_outlined),
|
||||||
|
activeIcon: Icon(Icons.home),
|
||||||
|
label: 'Accueil',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.history_outlined),
|
||||||
|
activeIcon: Icon(Icons.history),
|
||||||
|
label: 'Historique',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.analytics_outlined),
|
||||||
|
activeIcon: Icon(Icons.analytics),
|
||||||
|
label: 'Stats',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.shield_outlined),
|
||||||
|
activeIcon: Icon(Icons.shield),
|
||||||
|
label: 'Armurerie',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
@@ -37,7 +38,7 @@ class AiExportService {
|
|||||||
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
|
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de la récupération des infos appareil: $e');
|
debugPrint('Erreur lors de la récupération des infos appareil: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
return deviceData;
|
return deviceData;
|
||||||
@@ -52,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 {
|
||||||
@@ -108,9 +111,9 @@ 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": {
|
||||||
"target_corners": corners,
|
"target_corners": corners,
|
||||||
@@ -131,15 +134,15 @@ class AiExportService {
|
|||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final responseData = await response.stream.bytesToString();
|
final responseData = await response.stream.bytesToString();
|
||||||
print('Export réussi: $responseData');
|
debugPrint('Export réussi: $responseData');
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
final errorData = await response.stream.bytesToString();
|
final errorData = await response.stream.bytesToString();
|
||||||
print('Erreur d\'export: ${response.statusCode} - $errorData');
|
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Exception lors de l\'export: $e');
|
debugPrint('Exception lors de l\'export: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,14 @@
|
|||||||
/// Service de recadrage d'images.
|
import 'dart:isolate'; // AJOUT
|
||||||
///
|
|
||||||
/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder
|
|
||||||
/// dans un fichier temporaire pour utilisation ultérieure.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// Représente une zone de recadrage normalisée (0.0 à 1.0)
|
|
||||||
class CropRect {
|
class CropRect {
|
||||||
/// Position X du coin supérieur gauche (0.0 à 1.0)
|
|
||||||
final double x;
|
final double x;
|
||||||
|
|
||||||
/// Position Y du coin supérieur gauche (0.0 à 1.0)
|
|
||||||
final double y;
|
final double y;
|
||||||
|
|
||||||
/// Largeur de la zone (0.0 à 1.0)
|
|
||||||
final double width;
|
final double width;
|
||||||
|
|
||||||
/// Hauteur de la zone (0.0 à 1.0)
|
|
||||||
final double height;
|
final double height;
|
||||||
|
|
||||||
const CropRect({
|
const CropRect({
|
||||||
@@ -35,97 +22,268 @@ class CropRect {
|
|||||||
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
|
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service pour recadrer les images
|
// AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable)
|
||||||
|
class _CropParams {
|
||||||
|
final String sourcePath;
|
||||||
|
final double cropX;
|
||||||
|
final double cropY;
|
||||||
|
final double cropWidth;
|
||||||
|
final double cropHeight;
|
||||||
|
final double rotationDegrees;
|
||||||
|
final int outputSize;
|
||||||
|
final String outputPath;
|
||||||
|
|
||||||
|
_CropParams({
|
||||||
|
required this.sourcePath,
|
||||||
|
required this.cropX,
|
||||||
|
required this.cropY,
|
||||||
|
required this.cropWidth,
|
||||||
|
required this.cropHeight,
|
||||||
|
required this.rotationDegrees,
|
||||||
|
required this.outputSize,
|
||||||
|
required this.outputPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static)
|
||||||
|
void _cropIsolateEntry(_CropParams params) {
|
||||||
|
final file = File(params.sourcePath);
|
||||||
|
final bytes = file.readAsBytesSync();
|
||||||
|
img.Image? originalImage = img.decodeImage(bytes);
|
||||||
|
|
||||||
|
if (originalImage == null) {
|
||||||
|
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
|
||||||
|
if (params.rotationDegrees != 0.0) {
|
||||||
|
originalImage = img.copyRotate(
|
||||||
|
originalImage,
|
||||||
|
angle: params.rotationDegrees,
|
||||||
|
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recadrage
|
||||||
|
final srcX = (params.cropX * originalImage.width).round();
|
||||||
|
final srcY = (params.cropY * originalImage.height).round();
|
||||||
|
final srcWidth = (params.cropWidth * originalImage.width).round();
|
||||||
|
final srcHeight = (params.cropHeight * originalImage.height).round();
|
||||||
|
|
||||||
|
final clampedX = srcX.clamp(0, originalImage.width - 1);
|
||||||
|
final clampedY = srcY.clamp(0, originalImage.height - 1);
|
||||||
|
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
|
||||||
|
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
|
||||||
|
|
||||||
|
img.Image cropped = img.copyCrop(
|
||||||
|
originalImage,
|
||||||
|
x: clampedX,
|
||||||
|
y: clampedY,
|
||||||
|
width: clampedWidth,
|
||||||
|
height: clampedHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redimensionnement — LINEAR au lieu de CUBIC
|
||||||
|
if (cropped.width != params.outputSize || cropped.height != params.outputSize) {
|
||||||
|
cropped = img.copyResize(
|
||||||
|
cropped,
|
||||||
|
width: params.outputSize,
|
||||||
|
height: params.outputSize,
|
||||||
|
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable)
|
||||||
|
final outputFile = File(params.outputPath);
|
||||||
|
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
/// Taille de sortie maximale pour les images recadrées
|
|
||||||
static const int maxOutputSize = 1024;
|
static const int maxOutputSize = 1024;
|
||||||
|
|
||||||
/// Recadre une image en format carré
|
/// 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).
|
||||||
///
|
///
|
||||||
/// [sourcePath] - Chemin vers l'image source
|
/// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran.
|
||||||
/// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0)
|
/// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain).
|
||||||
/// [outputSize] - Taille de sortie en pixels (carré)
|
/// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran.
|
||||||
///
|
Future<String> cropViewport({
|
||||||
/// Retourne le chemin vers l'image recadrée dans le dossier temporaire
|
required String sourcePath,
|
||||||
Future<String> cropToSquare(
|
required double offsetDx,
|
||||||
String sourcePath,
|
required double offsetDy,
|
||||||
CropRect cropRect, {
|
required double displayPerSourcePx,
|
||||||
|
required double cropSizeDisplay,
|
||||||
|
double zoomScale = 1.0,
|
||||||
|
double rotationDegrees = 0.0,
|
||||||
int outputSize = maxOutputSize,
|
int outputSize = maxOutputSize,
|
||||||
}) async {
|
}) async {
|
||||||
// Charger l'image source
|
final tempDir = await getTemporaryDirectory();
|
||||||
final file = File(sourcePath);
|
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
|
||||||
final bytes = await file.readAsBytes();
|
|
||||||
final originalImage = img.decodeImage(bytes);
|
|
||||||
|
|
||||||
if (originalImage == null) {
|
final params = _ViewportCropParams(
|
||||||
throw Exception('Impossible de décoder l\'image: $sourcePath');
|
sourcePath: sourcePath,
|
||||||
}
|
offsetDx: offsetDx,
|
||||||
|
offsetDy: offsetDy,
|
||||||
// Calculer les coordonnées en pixels
|
displayPerSourcePx: displayPerSourcePx,
|
||||||
final srcX = (cropRect.x * originalImage.width).round();
|
cropSizeDisplay: cropSizeDisplay,
|
||||||
final srcY = (cropRect.y * originalImage.height).round();
|
zoomScale: zoomScale <= 0 ? 1.0 : zoomScale,
|
||||||
final srcWidth = (cropRect.width * originalImage.width).round();
|
rotationDegrees: rotationDegrees,
|
||||||
final srcHeight = (cropRect.height * originalImage.height).round();
|
outputSize: outputSize,
|
||||||
|
outputPath: outputPath,
|
||||||
// S'assurer que les dimensions sont valides
|
|
||||||
final clampedX = srcX.clamp(0, originalImage.width - 1);
|
|
||||||
final clampedY = srcY.clamp(0, originalImage.height - 1);
|
|
||||||
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
|
|
||||||
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
|
|
||||||
|
|
||||||
// Recadrer l'image
|
|
||||||
img.Image cropped = img.copyCrop(
|
|
||||||
originalImage,
|
|
||||||
x: clampedX,
|
|
||||||
y: clampedY,
|
|
||||||
width: clampedWidth,
|
|
||||||
height: clampedHeight,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Redimensionner à la taille de sortie si nécessaire
|
// Traitement lourd dans un Isolate → thread UI fluide.
|
||||||
if (cropped.width != outputSize || cropped.height != outputSize) {
|
await Isolate.run(() => _viewportCropIsolateEntry(params));
|
||||||
cropped = img.copyResize(
|
|
||||||
cropped,
|
|
||||||
width: outputSize,
|
|
||||||
height: outputSize,
|
|
||||||
interpolation: img.Interpolation.cubic,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sauvegarder dans le dossier temporaire
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> cropToSquare(
|
||||||
|
String sourcePath,
|
||||||
|
CropRect cropRect, {
|
||||||
|
double rotationDegrees = 0.0,
|
||||||
|
int outputSize = maxOutputSize,
|
||||||
|
}) async {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final fileName = 'cropped_${_uuid.v4()}.jpg';
|
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
|
||||||
final outputPath = '${tempDir.path}/$fileName';
|
|
||||||
|
|
||||||
final outputFile = File(outputPath);
|
final params = _CropParams(
|
||||||
await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90));
|
sourcePath: sourcePath,
|
||||||
|
cropX: cropRect.x,
|
||||||
|
cropY: cropRect.y,
|
||||||
|
cropWidth: cropRect.width,
|
||||||
|
cropHeight: cropRect.height,
|
||||||
|
rotationDegrees: rotationDegrees,
|
||||||
|
outputSize: outputSize,
|
||||||
|
outputPath: outputPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé
|
||||||
|
// → le thread UI reste fluide pendant le calcul
|
||||||
|
await Isolate.run(() => _cropIsolateEntry(params));
|
||||||
|
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calcule la zone de recadrage carrée maximale centrée sur l'image
|
/// Calcule la zone de recadrage carrée maximale centrée sur l'image
|
||||||
///
|
|
||||||
/// [imageWidth] - Largeur de l'image en pixels
|
|
||||||
/// [imageHeight] - Hauteur de l'image en pixels
|
|
||||||
///
|
|
||||||
/// Retourne un CropRect normalisé pour un carré centré
|
|
||||||
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
|
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
|
||||||
final aspectRatio = imageWidth / imageHeight;
|
final aspectRatio = imageWidth / imageHeight;
|
||||||
|
|
||||||
if (aspectRatio > 1) {
|
if (aspectRatio > 1) {
|
||||||
// Image plus large que haute - centrer horizontalement
|
|
||||||
final squareWidth = imageHeight / imageWidth;
|
final squareWidth = imageHeight / imageWidth;
|
||||||
final x = (1 - squareWidth) / 2;
|
final x = (1 - squareWidth) / 2;
|
||||||
return CropRect(x: x, y: 0, width: squareWidth, height: 1);
|
return CropRect(x: x, y: 0, width: squareWidth, height: 1);
|
||||||
} else if (aspectRatio < 1) {
|
} else if (aspectRatio < 1) {
|
||||||
// Image plus haute que large - centrer verticalement
|
|
||||||
final squareHeight = imageWidth / imageHeight;
|
final squareHeight = imageWidth / imageHeight;
|
||||||
final y = (1 - squareHeight) / 2;
|
final y = (1 - squareHeight) / 2;
|
||||||
return CropRect(x: 0, y: y, width: 1, height: squareHeight);
|
return CropRect(x: 0, y: y, width: 1, height: squareHeight);
|
||||||
} else {
|
} else {
|
||||||
// Déjà carré
|
|
||||||
return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +305,39 @@ class ImageCropService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignorer les erreurs de nettoyage
|
// Ignorer les erreurs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// Coupe automatiquement une photo brute de l'appareil photo en carré
|
||||||
|
/// calé sur la position haute du viseur de l'écran.
|
||||||
|
Future<String> cropRawCameraToSquare(String sourcePath) async {
|
||||||
|
final file = File(sourcePath);
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final originalImage = img.decodeImage(bytes);
|
||||||
|
|
||||||
|
if (originalImage == null) return sourcePath;
|
||||||
|
|
||||||
|
final int imgW = originalImage.width;
|
||||||
|
final int imgH = originalImage.height;
|
||||||
|
|
||||||
|
final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round();
|
||||||
|
|
||||||
|
final int x = ((imgW - cropSizePixels) / 2).round();
|
||||||
|
final int y = ((imgH - cropSizePixels) / 2).round();
|
||||||
|
|
||||||
|
final squareImage = img.copyCrop(
|
||||||
|
originalImage,
|
||||||
|
x: x.clamp(0, imgW - 1),
|
||||||
|
y: y.clamp(0, imgH - 1),
|
||||||
|
width: cropSizePixels,
|
||||||
|
height: cropSizePixels,
|
||||||
|
);
|
||||||
|
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85));
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
161
lib/services/parallelism_service.dart
Normal file
161
lib/services/parallelism_service.dart
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:sensors_plus/sensors_plus.dart';
|
||||||
|
|
||||||
|
/// Statut de parallélisme retourné en temps réel.
|
||||||
|
enum ParallelismStatus {
|
||||||
|
/// Pas encore de données capteur disponibles.
|
||||||
|
unknown,
|
||||||
|
|
||||||
|
/// L'appareil est bien parallèle à la cible.
|
||||||
|
aligned,
|
||||||
|
|
||||||
|
/// L'appareil est trop incliné.
|
||||||
|
misaligned,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Données de parallélisme calculées à chaque frame capteur.
|
||||||
|
class ParallelismData {
|
||||||
|
final ParallelismStatus status;
|
||||||
|
|
||||||
|
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
|
||||||
|
final double pitchDegrees;
|
||||||
|
|
||||||
|
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
|
||||||
|
final double rollDegrees;
|
||||||
|
|
||||||
|
const ParallelismData({
|
||||||
|
required this.status,
|
||||||
|
required this.pitchDegrees,
|
||||||
|
required this.rollDegrees,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isAligned => status == ParallelismStatus.aligned;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service de détection du parallélisme par accéléromètre.
|
||||||
|
///
|
||||||
|
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
|
||||||
|
///
|
||||||
|
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
|
||||||
|
/// État actuel = aligned → passe à misaligned si angle > [misalignThreshold]
|
||||||
|
///
|
||||||
|
/// Cela crée une "zone de confort" entre les deux seuils où le statut
|
||||||
|
/// ne change pas — le vert reste vert même si la main tremble légèrement.
|
||||||
|
///
|
||||||
|
/// Valeurs par défaut recommandées :
|
||||||
|
/// alignThreshold = 15° (seuil d'entrée dans le vert — assez souple)
|
||||||
|
/// misalignThreshold = 22° (seuil de sortie du vert — tolérant au tremblement)
|
||||||
|
class ParallelismService {
|
||||||
|
/// Angle max pour passer de misaligned → aligned.
|
||||||
|
final double alignThreshold;
|
||||||
|
|
||||||
|
/// Angle à dépasser pour passer de aligned → misaligned.
|
||||||
|
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
|
||||||
|
final double misalignThreshold;
|
||||||
|
|
||||||
|
StreamSubscription<AccelerometerEvent>? _subscription;
|
||||||
|
final StreamController<ParallelismData> _controller =
|
||||||
|
StreamController<ParallelismData>.broadcast();
|
||||||
|
|
||||||
|
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
|
||||||
|
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
|
||||||
|
|
||||||
|
ParallelismService({
|
||||||
|
this.alignThreshold = 25.0,
|
||||||
|
this.misalignThreshold = 32.0,
|
||||||
|
}) : assert(
|
||||||
|
misalignThreshold > alignThreshold,
|
||||||
|
'misalignThreshold doit être supérieur à alignThreshold',
|
||||||
|
);
|
||||||
|
|
||||||
|
Stream<ParallelismData> get stream => _controller.stream;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
if (_subscription != null) return;
|
||||||
|
|
||||||
|
_subscription = accelerometerEventStream(
|
||||||
|
samplingPeriod: SensorInterval.normalInterval, // ~50 ms
|
||||||
|
).listen(
|
||||||
|
_onAccelerometerEvent,
|
||||||
|
onError: (_) {
|
||||||
|
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
|
||||||
|
if (!_controller.isClosed) {
|
||||||
|
_currentStatus = ParallelismStatus.unknown;
|
||||||
|
_controller.add(const ParallelismData(
|
||||||
|
status: ParallelismStatus.unknown,
|
||||||
|
pitchDegrees: 0,
|
||||||
|
rollDegrees: 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
_subscription?.cancel();
|
||||||
|
_subscription = null;
|
||||||
|
_currentStatus = ParallelismStatus.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
stop();
|
||||||
|
_controller.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAccelerometerEvent(AccelerometerEvent event) {
|
||||||
|
if (_controller.isClosed) return;
|
||||||
|
|
||||||
|
final double gx = event.x;
|
||||||
|
final double gy = event.y;
|
||||||
|
final double gz = event.z;
|
||||||
|
|
||||||
|
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
|
||||||
|
if (magnitude < 1.0) return; // Données aberrantes
|
||||||
|
|
||||||
|
// Normalisation par la magnitude réelle (indépendant de g exact)
|
||||||
|
final double nx = gx / magnitude;
|
||||||
|
final double nz = gz / magnitude;
|
||||||
|
|
||||||
|
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
||||||
|
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
||||||
|
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
||||||
|
|
||||||
|
// Le critère de couleur = le PIRE des deux angles affichés à l'écran.
|
||||||
|
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement
|
||||||
|
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert.
|
||||||
|
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs());
|
||||||
|
|
||||||
|
// ── Hystérésis ──────────────────────────────────────────────────────────
|
||||||
|
// Premier appel : on décide selon alignThreshold uniquement
|
||||||
|
if (_currentStatus == ParallelismStatus.unknown) {
|
||||||
|
_currentStatus = worstAngle <= alignThreshold
|
||||||
|
? ParallelismStatus.aligned
|
||||||
|
: ParallelismStatus.misaligned;
|
||||||
|
}
|
||||||
|
// Déjà misaligned → devient aligned seulement si on passe sous alignThreshold
|
||||||
|
else if (_currentStatus == ParallelismStatus.misaligned) {
|
||||||
|
if (worstAngle <= alignThreshold) {
|
||||||
|
_currentStatus = ParallelismStatus.aligned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Déjà aligned → devient misaligned seulement si on dépasse misalignThreshold
|
||||||
|
else if (_currentStatus == ParallelismStatus.aligned) {
|
||||||
|
if (worstAngle > misalignThreshold) {
|
||||||
|
_currentStatus = ParallelismStatus.misaligned;
|
||||||
|
}
|
||||||
|
// Entre alignThreshold et misalignThreshold → on garde le vert, on ne change rien
|
||||||
|
}
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_controller.add(ParallelismData(
|
||||||
|
status: _currentStatus,
|
||||||
|
pitchDegrees: pitchDeg,
|
||||||
|
rollDegrees: rollDeg,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,25 +66,12 @@ class PrecisionStats {
|
|||||||
|
|
||||||
/// Standard deviation statistics
|
/// Standard deviation statistics
|
||||||
class StdDevStats {
|
class StdDevStats {
|
||||||
/// Standard deviation of X positions
|
|
||||||
final double stdDevX;
|
final double stdDevX;
|
||||||
|
|
||||||
/// Standard deviation of Y positions
|
|
||||||
final double stdDevY;
|
final double stdDevY;
|
||||||
|
|
||||||
/// Combined standard deviation (radial)
|
|
||||||
final double stdDevRadial;
|
final double stdDevRadial;
|
||||||
|
|
||||||
/// Standard deviation of scores
|
|
||||||
final double stdDevScore;
|
final double stdDevScore;
|
||||||
|
|
||||||
/// Mean X position
|
|
||||||
final double meanX;
|
final double meanX;
|
||||||
|
|
||||||
/// Mean Y position
|
|
||||||
final double meanY;
|
final double meanY;
|
||||||
|
|
||||||
/// Mean score
|
|
||||||
final double meanScore;
|
final double meanScore;
|
||||||
|
|
||||||
const StdDevStats({
|
const StdDevStats({
|
||||||
@@ -98,18 +85,11 @@ class StdDevStats {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regional distribution (quadrants or sectors)
|
/// Regional distribution
|
||||||
class RegionalStats {
|
class RegionalStats {
|
||||||
/// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right)
|
|
||||||
final Map<String, int> quadrantDistribution;
|
final Map<String, int> quadrantDistribution;
|
||||||
|
|
||||||
/// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center)
|
|
||||||
final Map<String, int> sectorDistribution;
|
final Map<String, int> sectorDistribution;
|
||||||
|
|
||||||
/// Dominant direction (where most shots land)
|
|
||||||
final String dominantDirection;
|
final String dominantDirection;
|
||||||
|
|
||||||
/// Bias offset from center
|
|
||||||
final double biasX;
|
final double biasX;
|
||||||
final double biasY;
|
final double biasY;
|
||||||
|
|
||||||
@@ -157,16 +137,16 @@ class StatisticsService {
|
|||||||
SessionStatistics calculateStatistics(
|
SessionStatistics calculateStatistics(
|
||||||
List<Session> sessions, {
|
List<Session> sessions, {
|
||||||
StatsPeriod period = StatsPeriod.all,
|
StatsPeriod period = StatsPeriod.all,
|
||||||
double targetCenterX = 0.5,
|
|
||||||
double targetCenterY = 0.5,
|
|
||||||
}) {
|
}) {
|
||||||
// Filter sessions by period
|
// Filter sessions by period
|
||||||
final filteredSessions = _filterByPeriod(sessions, period);
|
final filteredSessions = _filterByPeriod(sessions, period);
|
||||||
|
|
||||||
// Collect all shots
|
// Collect all shots from all analyses in all sessions
|
||||||
final allShots = <Shot>[];
|
final allShots = <Shot>[];
|
||||||
for (final session in filteredSessions) {
|
for (final session in filteredSessions) {
|
||||||
allShots.addAll(session.shots);
|
for (final analysis in session.analyses) {
|
||||||
|
allShots.addAll(analysis.shots);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allShots.isEmpty) {
|
if (allShots.isEmpty) {
|
||||||
@@ -183,14 +163,14 @@ class StatisticsService {
|
|||||||
// Calculate heat map
|
// Calculate heat map
|
||||||
final heatMap = _calculateHeatMap(allShots, gridSize: 5);
|
final heatMap = _calculateHeatMap(allShots, gridSize: 5);
|
||||||
|
|
||||||
// Calculate precision
|
// Calculate precision (using relative center 0.5, 0.5)
|
||||||
final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY);
|
final precision = _calculatePrecision(allShots, 0.5, 0.5);
|
||||||
|
|
||||||
// Calculate standard deviation
|
// Calculate standard deviation
|
||||||
final stdDev = _calculateStdDev(allShots);
|
final stdDev = _calculateStdDev(allShots);
|
||||||
|
|
||||||
// Calculate regional distribution
|
// Calculate regional distribution
|
||||||
final regional = _calculateRegional(allShots, targetCenterX, targetCenterY);
|
final regional = _calculateRegional(allShots, 0.5, 0.5);
|
||||||
|
|
||||||
return SessionStatistics(
|
return SessionStatistics(
|
||||||
totalShots: totalShots,
|
totalShots: totalShots,
|
||||||
@@ -224,20 +204,13 @@ class StatisticsService {
|
|||||||
|
|
||||||
/// Calculate heat map
|
/// Calculate heat map
|
||||||
HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) {
|
HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) {
|
||||||
// Initialize grid
|
final grid = List.generate(gridSize, (_) => List.generate(gridSize, (_) => <Shot>[]));
|
||||||
final grid = List.generate(
|
|
||||||
gridSize,
|
|
||||||
(_) => List.generate(gridSize, (_) => <Shot>[]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assign shots to grid cells
|
|
||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1);
|
final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1);
|
||||||
final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1);
|
final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1);
|
||||||
grid[row][col].add(shot);
|
grid[row][col].add(shot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find max count for normalization
|
|
||||||
int maxCount = 0;
|
int maxCount = 0;
|
||||||
for (final row in grid) {
|
for (final row in grid) {
|
||||||
for (final cell in row) {
|
for (final cell in row) {
|
||||||
@@ -245,16 +218,12 @@ class StatisticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create heat zones
|
|
||||||
final zones = <List<HeatZone>>[];
|
final zones = <List<HeatZone>>[];
|
||||||
for (int row = 0; row < gridSize; row++) {
|
for (int row = 0; row < gridSize; row++) {
|
||||||
final rowZones = <HeatZone>[];
|
final rowZones = <HeatZone>[];
|
||||||
for (int col = 0; col < gridSize; col++) {
|
for (int col = 0; col < gridSize; col++) {
|
||||||
final cellShots = grid[row][col];
|
final cellShots = grid[row][col];
|
||||||
final avgScore = cellShots.isEmpty
|
final avgScore = cellShots.isEmpty ? 0.0 : cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
|
||||||
? 0.0
|
|
||||||
: cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
|
|
||||||
|
|
||||||
rowZones.add(HeatZone(
|
rowZones.add(HeatZone(
|
||||||
row: row,
|
row: row,
|
||||||
col: col,
|
col: col,
|
||||||
@@ -266,30 +235,15 @@ class StatisticsService {
|
|||||||
zones.add(rowZones);
|
zones.add(rowZones);
|
||||||
}
|
}
|
||||||
|
|
||||||
return HeatMap(
|
return HeatMap(gridSize: gridSize, zones: zones, maxShotsInZone: maxCount, totalShots: shots.length);
|
||||||
gridSize: gridSize,
|
|
||||||
zones: zones,
|
|
||||||
maxShotsInZone: maxCount,
|
|
||||||
totalShots: shots.length,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate precision statistics
|
/// Calculate precision statistics
|
||||||
PrecisionStats _calculatePrecision(
|
PrecisionStats _calculatePrecision(List<Shot> shots, double centerX, double centerY) {
|
||||||
List<Shot> shots,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
) {
|
|
||||||
if (shots.isEmpty) {
|
if (shots.isEmpty) {
|
||||||
return const PrecisionStats(
|
return const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0);
|
||||||
avgDistanceFromCenter: 0,
|
|
||||||
groupingDiameter: 0,
|
|
||||||
precisionScore: 0,
|
|
||||||
consistencyScore: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate distances from center
|
|
||||||
final distances = shots.map((shot) {
|
final distances = shots.map((shot) {
|
||||||
final dx = shot.x - centerX;
|
final dx = shot.x - centerX;
|
||||||
final dy = shot.y - centerY;
|
final dy = shot.y - centerY;
|
||||||
@@ -297,8 +251,6 @@ class StatisticsService {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final avgDistance = distances.reduce((a, b) => a + b) / distances.length;
|
final avgDistance = distances.reduce((a, b) => a + b) / distances.length;
|
||||||
|
|
||||||
// Calculate grouping (spread between shots)
|
|
||||||
double maxSpread = 0;
|
double maxSpread = 0;
|
||||||
for (int i = 0; i < shots.length; i++) {
|
for (int i = 0; i < shots.length; i++) {
|
||||||
for (int j = i + 1; j < shots.length; j++) {
|
for (int j = i + 1; j < shots.length; j++) {
|
||||||
@@ -309,45 +261,29 @@ class StatisticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate standard deviation of distances (consistency)
|
|
||||||
final meanDist = avgDistance;
|
|
||||||
double variance = 0;
|
double variance = 0;
|
||||||
for (final d in distances) {
|
for (final d in distances) {
|
||||||
variance += math.pow(d - meanDist, 2);
|
variance += math.pow(d - avgDistance, 2);
|
||||||
}
|
}
|
||||||
final stdDevDist = math.sqrt(variance / distances.length);
|
final stdDevDist = math.sqrt(variance / distances.length);
|
||||||
|
|
||||||
// Precision score: based on average distance from center (0-100)
|
|
||||||
// 0 distance = 100 score, 0.5 distance = 0 score
|
|
||||||
final precisionScore = math.max(0, (1 - avgDistance * 2) * 100);
|
final precisionScore = math.max(0, (1 - avgDistance * 2) * 100);
|
||||||
|
|
||||||
// Consistency score: based on grouping tightness (0-100)
|
|
||||||
// Lower spread = higher consistency
|
|
||||||
final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100);
|
final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100);
|
||||||
|
|
||||||
return PrecisionStats(
|
return PrecisionStats(
|
||||||
avgDistanceFromCenter: avgDistance.toDouble(),
|
avgDistanceFromCenter: avgDistance,
|
||||||
groupingDiameter: maxSpread.toDouble(),
|
groupingDiameter: maxSpread,
|
||||||
precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(),
|
precisionScore: precisionScore.toDouble().clamp(0.0, 100.0),
|
||||||
consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(),
|
consistencyScore: consistencyScore.toDouble().clamp(0.0, 100.0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate standard deviation statistics
|
/// Calculate standard deviation statistics
|
||||||
StdDevStats _calculateStdDev(List<Shot> shots) {
|
StdDevStats _calculateStdDev(List<Shot> shots) {
|
||||||
if (shots.isEmpty) {
|
if (shots.isEmpty) {
|
||||||
return const StdDevStats(
|
return const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0);
|
||||||
stdDevX: 0,
|
|
||||||
stdDevY: 0,
|
|
||||||
stdDevRadial: 0,
|
|
||||||
stdDevScore: 0,
|
|
||||||
meanX: 0.5,
|
|
||||||
meanY: 0.5,
|
|
||||||
meanScore: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate means
|
|
||||||
double sumX = 0, sumY = 0, sumScore = 0;
|
double sumX = 0, sumY = 0, sumScore = 0;
|
||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
sumX += shot.x;
|
sumX += shot.x;
|
||||||
@@ -358,7 +294,6 @@ class StatisticsService {
|
|||||||
final meanY = sumY / shots.length;
|
final meanY = sumY / shots.length;
|
||||||
final meanScore = sumScore / shots.length;
|
final meanScore = sumScore / shots.length;
|
||||||
|
|
||||||
// Calculate variances
|
|
||||||
double varianceX = 0, varianceY = 0, varianceScore = 0;
|
double varianceX = 0, varianceY = 0, varianceScore = 0;
|
||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
varianceX += math.pow(shot.x - meanX, 2);
|
varianceX += math.pow(shot.x - meanX, 2);
|
||||||
@@ -369,18 +304,11 @@ class StatisticsService {
|
|||||||
varianceY /= shots.length;
|
varianceY /= shots.length;
|
||||||
varianceScore /= shots.length;
|
varianceScore /= shots.length;
|
||||||
|
|
||||||
final stdDevX = math.sqrt(varianceX);
|
|
||||||
final stdDevY = math.sqrt(varianceY);
|
|
||||||
final stdDevScore = math.sqrt(varianceScore);
|
|
||||||
|
|
||||||
// Radial standard deviation
|
|
||||||
final stdDevRadial = math.sqrt(varianceX + varianceY);
|
|
||||||
|
|
||||||
return StdDevStats(
|
return StdDevStats(
|
||||||
stdDevX: stdDevX,
|
stdDevX: math.sqrt(varianceX),
|
||||||
stdDevY: stdDevY,
|
stdDevY: math.sqrt(varianceY),
|
||||||
stdDevRadial: stdDevRadial,
|
stdDevRadial: math.sqrt(varianceX + varianceY),
|
||||||
stdDevScore: stdDevScore,
|
stdDevScore: math.sqrt(varianceScore),
|
||||||
meanX: meanX,
|
meanX: meanX,
|
||||||
meanY: meanY,
|
meanY: meanY,
|
||||||
meanScore: meanScore,
|
meanScore: meanScore,
|
||||||
@@ -388,42 +316,13 @@ class StatisticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate regional distribution
|
/// Calculate regional distribution
|
||||||
RegionalStats _calculateRegional(
|
RegionalStats _calculateRegional(List<Shot> shots, double centerX, double centerY) {
|
||||||
List<Shot> shots,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
) {
|
|
||||||
if (shots.isEmpty) {
|
if (shots.isEmpty) {
|
||||||
return const RegionalStats(
|
return const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0);
|
||||||
quadrantDistribution: {},
|
|
||||||
sectorDistribution: {},
|
|
||||||
dominantDirection: 'Centre',
|
|
||||||
biasX: 0,
|
|
||||||
biasY: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quadrant distribution
|
final quadrants = <String, int>{'Haut-Gauche': 0, 'Haut-Droite': 0, 'Bas-Gauche': 0, 'Bas-Droite': 0};
|
||||||
final quadrants = <String, int>{
|
final sectors = <String, int>{'N': 0, 'NE': 0, 'E': 0, 'SE': 0, 'S': 0, 'SO': 0, 'O': 0, 'NO': 0, 'Centre': 0};
|
||||||
'Haut-Gauche': 0,
|
|
||||||
'Haut-Droite': 0,
|
|
||||||
'Bas-Gauche': 0,
|
|
||||||
'Bas-Droite': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sector distribution (8 sectors + center)
|
|
||||||
final sectors = <String, int>{
|
|
||||||
'N': 0,
|
|
||||||
'NE': 0,
|
|
||||||
'E': 0,
|
|
||||||
'SE': 0,
|
|
||||||
'S': 0,
|
|
||||||
'SO': 0,
|
|
||||||
'O': 0,
|
|
||||||
'NO': 0,
|
|
||||||
'Centre': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
double sumDx = 0, sumDy = 0;
|
double sumDx = 0, sumDy = 0;
|
||||||
|
|
||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
@@ -432,16 +331,12 @@ class StatisticsService {
|
|||||||
sumDx += dx;
|
sumDx += dx;
|
||||||
sumDy += dy;
|
sumDy += dy;
|
||||||
|
|
||||||
// Quadrant
|
|
||||||
if (dy < 0) {
|
if (dy < 0) {
|
||||||
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] =
|
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
|
||||||
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
|
|
||||||
} else {
|
} else {
|
||||||
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] =
|
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
|
||||||
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sector
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy);
|
final distance = math.sqrt(dx * dx + dy * dy);
|
||||||
if (distance < 0.1) {
|
if (distance < 0.1) {
|
||||||
sectors['Centre'] = sectors['Centre']! + 1;
|
sectors['Centre'] = sectors['Centre']! + 1;
|
||||||
@@ -452,11 +347,6 @@ class StatisticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate bias
|
|
||||||
final biasX = sumDx / shots.length;
|
|
||||||
final biasY = sumDy / shots.length;
|
|
||||||
|
|
||||||
// Find dominant direction
|
|
||||||
String dominant = 'Centre';
|
String dominant = 'Centre';
|
||||||
int maxCount = 0;
|
int maxCount = 0;
|
||||||
sectors.forEach((key, value) {
|
sectors.forEach((key, value) {
|
||||||
@@ -470,14 +360,12 @@ class StatisticsService {
|
|||||||
quadrantDistribution: quadrants,
|
quadrantDistribution: quadrants,
|
||||||
sectorDistribution: sectors,
|
sectorDistribution: sectors,
|
||||||
dominantDirection: dominant,
|
dominantDirection: dominant,
|
||||||
biasX: biasX,
|
biasX: sumDx / shots.length,
|
||||||
biasY: biasY,
|
biasY: sumDy / shots.length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _angleToSector(double angle) {
|
String _angleToSector(double angle) {
|
||||||
// Angle is in degrees, -180 to 180
|
|
||||||
// 0 = East, 90 = South, -90 = North, 180/-180 = West
|
|
||||||
if (angle >= -22.5 && angle < 22.5) return 'E';
|
if (angle >= -22.5 && angle < 22.5) return 'E';
|
||||||
if (angle >= 22.5 && angle < 67.5) return 'SE';
|
if (angle >= 22.5 && angle < 67.5) return 'SE';
|
||||||
if (angle >= 67.5 && angle < 112.5) return 'S';
|
if (angle >= 67.5 && angle < 112.5) return 'S';
|
||||||
@@ -491,41 +379,12 @@ class StatisticsService {
|
|||||||
|
|
||||||
SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) {
|
SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) {
|
||||||
return SessionStatistics(
|
return SessionStatistics(
|
||||||
totalShots: 0,
|
totalShots: 0, totalScore: 0, avgScore: 0, maxScore: 0, minScore: 0,
|
||||||
totalScore: 0,
|
heatMap: const HeatMap(gridSize: 5, zones: [], maxShotsInZone: 0, totalShots: 0),
|
||||||
avgScore: 0,
|
precision: const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0),
|
||||||
maxScore: 0,
|
stdDev: const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0),
|
||||||
minScore: 0,
|
regional: const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0),
|
||||||
heatMap: const HeatMap(
|
sessions: sessions, period: period,
|
||||||
gridSize: 5,
|
|
||||||
zones: [],
|
|
||||||
maxShotsInZone: 0,
|
|
||||||
totalShots: 0,
|
|
||||||
),
|
|
||||||
precision: const PrecisionStats(
|
|
||||||
avgDistanceFromCenter: 0,
|
|
||||||
groupingDiameter: 0,
|
|
||||||
precisionScore: 0,
|
|
||||||
consistencyScore: 0,
|
|
||||||
),
|
|
||||||
stdDev: const StdDevStats(
|
|
||||||
stdDevX: 0,
|
|
||||||
stdDevY: 0,
|
|
||||||
stdDevRadial: 0,
|
|
||||||
stdDevScore: 0,
|
|
||||||
meanX: 0.5,
|
|
||||||
meanY: 0.5,
|
|
||||||
meanScore: 0,
|
|
||||||
),
|
|
||||||
regional: const RegionalStats(
|
|
||||||
quadrantDistribution: {},
|
|
||||||
sectorDistribution: {},
|
|
||||||
dominantDirection: 'Centre',
|
|
||||||
biasX: 0,
|
|
||||||
biasY: 0,
|
|
||||||
),
|
|
||||||
sessions: sessions,
|
|
||||||
period: period,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
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;
|
|
||||||
|
|
||||||
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
|
|
||||||
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
|
|
||||||
try {
|
|
||||||
// Detect main target
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect impacts
|
|
||||||
final impacts = _imageProcessingService.detectImpacts(imagePath);
|
|
||||||
|
|
||||||
// Convert impacts to relative coordinates and calculate scores
|
|
||||||
final detectedImpacts = impacts.map((impact) {
|
|
||||||
final score = targetType == TargetType.concentric
|
|
||||||
? _calculateConcentricScore(
|
|
||||||
impact.x,
|
|
||||||
impact.y,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
radius,
|
|
||||||
)
|
|
||||||
: _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,
|
|
||||||
) {
|
|
||||||
// Calculate distance from center (normalized to target radius)
|
|
||||||
final dx = impactX - centerX;
|
|
||||||
final dy = impactY - centerY;
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
|
||||||
|
|
||||||
// Score zones (10 zones)
|
|
||||||
if (distance <= 0.1) return 10;
|
|
||||||
if (distance <= 0.2) return 9;
|
|
||||||
if (distance <= 0.3) return 8;
|
|
||||||
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; // Outside target
|
|
||||||
}
|
|
||||||
|
|
||||||
int _calculateSilhouetteScore(
|
|
||||||
double impactX,
|
|
||||||
double impactY,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
) {
|
|
||||||
// For silhouettes, scoring is typically based on zones
|
|
||||||
// Head and center mass = 5, body = 4, lower = 3
|
|
||||||
|
|
||||||
final dx = (impactX - centerX).abs();
|
|
||||||
final dy = impactY - centerY;
|
|
||||||
|
|
||||||
// Check if within silhouette bounds (approximate)
|
|
||||||
if (dx > 0.15) return 0; // Too far left/right
|
|
||||||
|
|
||||||
// Vertical zones
|
|
||||||
if (dy < -0.25) return 5; // Head zone (top)
|
|
||||||
if (dy < 0.0) return 5; // Center mass (upper body)
|
|
||||||
if (dy < 0.15) return 4; // Body
|
|
||||||
if (dy < 0.35) return 3; // Lower body
|
|
||||||
|
|
||||||
return 0; // Outside target
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect only impacts with custom settings (doesn't affect target position)
|
|
||||||
List<DetectedImpactResult> detectImpactsOnly(
|
|
||||||
String imagePath,
|
|
||||||
TargetType targetType,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double radius,
|
|
||||||
int ringCount,
|
|
||||||
ImpactDetectionSettings settings,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
// Detect impacts with custom settings
|
|
||||||
final impacts = _imageProcessingService.detectImpactsWithSettings(
|
|
||||||
imagePath,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert impacts to relative coordinates and calculate scores
|
|
||||||
return impacts.map((impact) {
|
|
||||||
final score = targetType == TargetType.concentric
|
|
||||||
? _calculateConcentricScoreWithRings(
|
|
||||||
impact.x,
|
|
||||||
impact.y,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
radius,
|
|
||||||
ringCount,
|
|
||||||
)
|
|
||||||
: _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,
|
|
||||||
) {
|
|
||||||
// Calculate distance from center (normalized to target radius)
|
|
||||||
final dx = impactX - centerX;
|
|
||||||
final dy = impactY - centerY;
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
|
||||||
|
|
||||||
// Score zones based on ringCount
|
|
||||||
for (int i = 0; i < ringCount; i++) {
|
|
||||||
final zoneRadius = (i + 1) / ringCount;
|
|
||||||
if (distance <= zoneRadius) {
|
|
||||||
return 10 - i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0; // Outside target
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Analyze reference impacts to learn their characteristics
|
|
||||||
ImpactCharacteristics? analyzeReferenceImpacts(
|
|
||||||
String imagePath,
|
|
||||||
List<ReferenceImpact> references,
|
|
||||||
) {
|
|
||||||
return _imageProcessingService.analyzeReferenceImpacts(
|
|
||||||
imagePath,
|
|
||||||
references,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect impacts based on reference characteristics (calibrated detection)
|
|
||||||
List<DetectedImpactResult> detectImpactsFromReferences(
|
|
||||||
String imagePath,
|
|
||||||
TargetType targetType,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double radius,
|
|
||||||
int ringCount,
|
|
||||||
ImpactCharacteristics characteristics, {
|
|
||||||
double tolerance = 2.0,
|
|
||||||
}) {
|
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Détecte les impacts en utilisant OpenCV (Hough Circles + Contours)
|
|
||||||
///
|
|
||||||
/// Cette méthode utilise les algorithmes OpenCV pour une détection plus robuste:
|
|
||||||
/// - Transformation de Hough pour détecter les cercles
|
|
||||||
/// - Analyse de contours avec filtrage par circularité
|
|
||||||
List<DetectedImpactResult> detectImpactsWithOpenCV(
|
|
||||||
String imagePath,
|
|
||||||
TargetType targetType,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double radius,
|
|
||||||
int ringCount, {
|
|
||||||
OpenCVDetectionSettings? settings,
|
|
||||||
}) {
|
|
||||||
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) {
|
|
||||||
print('Erreur détection OpenCV: $e');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Détecte les impacts avec OpenCV en utilisant des références
|
|
||||||
///
|
|
||||||
/// Analyse les impacts de référence pour apprendre leurs caractéristiques
|
|
||||||
/// puis détecte les impacts similaires dans l'image.
|
|
||||||
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
|
|
||||||
String imagePath,
|
|
||||||
TargetType targetType,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
double radius,
|
|
||||||
int ringCount,
|
|
||||||
List<ReferenceImpact> references, {
|
|
||||||
double tolerance = 2.0,
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
// Convertir les références au format OpenCV
|
|
||||||
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
|
|
||||||
|
|
||||||
final impacts = _opencvService.detectFromReferences(
|
|
||||||
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) {
|
|
||||||
print('Erreur détection OpenCV depuis références: $e');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class WalletIdentityService {
|
class WalletIdentityService {
|
||||||
static const String _prefsKey = 'wallet_identity_phrase';
|
static const String _prefsKey = 'wallet_identity_phrase';
|
||||||
@@ -86,7 +87,7 @@ class WalletIdentityService {
|
|||||||
deviceId = windowsInfo.deviceId;
|
deviceId = windowsInfo.deviceId;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lecture device ID: $e');
|
debugPrint('Erreur lecture device ID: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a salt to the device ID
|
// Add a salt to the device ID
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import Foundation
|
|||||||
|
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import path_provider_foundation
|
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
309
pubspec.lock
309
pubspec.lock
@@ -5,18 +5,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.7"
|
version: "4.0.9"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
version: "2.13.1"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -25,6 +33,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
camera:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: camera
|
||||||
|
sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.0+1"
|
||||||
|
camera_android_camerax:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: camera_android_camerax
|
||||||
|
sha256: b5064cf25a2787d122d0bf12e77c7b1033a2b983d0730e3091f770ee376efde5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
|
camera_avfoundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: camera_avfoundation
|
||||||
|
sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.1"
|
||||||
|
camera_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: camera_platform_interface
|
||||||
|
sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.0"
|
||||||
|
camera_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: camera_web
|
||||||
|
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+3"
|
||||||
change_case:
|
change_case:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -37,10 +85,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -85,18 +133,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: cupertino_icons
|
name: cupertino_icons
|
||||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.9"
|
||||||
dartcv4:
|
dartcv4:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dartcv4
|
name: dartcv4
|
||||||
sha256: "43dba49162662f3b6e3daf5a95d071429365e2f1ada67d412b851fc9be442e58"
|
sha256: "5764d13550055da3bb35ca28e6866b837579dfb4b594582b7a7753785ca5dfef"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1+1"
|
version: "2.2.1+4"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -197,10 +245,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: fl_chart
|
name: fl_chart
|
||||||
sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d"
|
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.66.2"
|
version: "1.2.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -214,14 +262,19 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_plugin_android_lifecycle
|
name: flutter_plugin_android_lifecycle
|
||||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.33"
|
version: "2.0.34"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -240,22 +293,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.3"
|
||||||
google_mlkit_document_scanner:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_mlkit_document_scanner
|
|
||||||
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.1"
|
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
|
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.3"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -276,26 +321,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.7.2"
|
version: "4.8.0"
|
||||||
image_picker:
|
image_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image_picker
|
name: image_picker
|
||||||
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.2"
|
||||||
image_picker_android:
|
image_picker_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image_picker_android
|
name: image_picker_android
|
||||||
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
|
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.13+13"
|
version: "0.8.13+17"
|
||||||
image_picker_for_web:
|
image_picker_for_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -348,10 +393,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.0"
|
version: "0.20.2"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -380,10 +441,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.1.0"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -396,26 +457,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -428,18 +489,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: native_toolchain_c
|
name: native_toolchain_c
|
||||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.4"
|
version: "0.17.6"
|
||||||
native_toolchain_cmake:
|
native_toolchain_cmake:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: native_toolchain_cmake
|
name: native_toolchain_cmake
|
||||||
sha256: fe40e8483183ced98e851e08a9cd2a547fd412cccab98277aa23f2377e43d66f
|
sha256: cd4865568c57f03c7ab1f138cc5032c5f77825d27083d6f5a3e9bbb3e8014a87
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.4"
|
version: "0.2.5"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -448,14 +509,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.3.0"
|
||||||
opencv_dart:
|
opencv_dart:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: opencv_dart
|
name: opencv_dart
|
||||||
sha256: c2b7cc614cad69c2857e9b684e3066af662a03fe7100f4dc9a630e81ad42103a
|
sha256: b50ea4788e6d61df6369874e0244445d98ef9210dbb90b8d3c1df198b4ee2333
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1+1"
|
version: "2.2.1+4"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -476,18 +553,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.22"
|
version: "2.3.1"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.1"
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -512,14 +589,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.1"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.0.1"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.7"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3+5"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -540,10 +665,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: posix
|
name: posix
|
||||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
version: "6.5.0"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -560,6 +685,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
record_use:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: record_use
|
||||||
|
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0"
|
||||||
|
sensors_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sensors_plus
|
||||||
|
sha256: "8e7fa79b4940442bb595bfc0ee9da4af5a22a0fe6ebacc74998245ee9496a82d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.2"
|
||||||
|
sensors_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sensors_plus_platform_interface
|
||||||
|
sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -625,42 +774,42 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.2"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqflite
|
name: sqflite
|
||||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2"
|
version: "2.4.2+1"
|
||||||
sqflite_android:
|
sqflite_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_android
|
name: sqflite_android
|
||||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2+2"
|
version: "2.4.2+3"
|
||||||
sqflite_common:
|
sqflite_common:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_common
|
name: sqflite_common
|
||||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
sha256: f8a08a13fb8f0f8c590df89d745000bed44a673ed94bac846739e1a016875c21
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.6"
|
version: "2.5.7"
|
||||||
sqflite_common_ffi:
|
sqflite_common_ffi:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqflite_common_ffi
|
name: sqflite_common_ffi
|
||||||
sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff
|
sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0+2"
|
version: "2.4.0+3"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -681,10 +830,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "00e5e65f8e9b556ed3d999ad310881c956ffb656ed96bea487a4c50ffdff6d14"
|
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.3.1"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -701,6 +850,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
string_scanner:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -729,10 +886,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.7"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -745,10 +902,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -761,10 +918,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.2.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -777,10 +934,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: ba7d5750e3441caa1bbe31d9e516348fcf8dfcb32aa29ef87a844a59f4d1f1d0
|
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.2.0"
|
||||||
win32_registry:
|
win32_registry:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -814,5 +971,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.0-35.0.dev <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.38.1"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
19
pubspec.yaml
19
pubspec.yaml
@@ -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
|
||||||
@@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0-35.0.dev
|
sdk: '>=3.10.0 <4.0.0'
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
@@ -31,15 +31,16 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
sensors_plus: ^4.0.2
|
||||||
opencv_dart: ^2.1.0
|
opencv_dart: ^2.1.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
|
||||||
@@ -56,20 +57,19 @@ dependencies:
|
|||||||
uuid: ^4.3.3
|
uuid: ^4.3.3
|
||||||
|
|
||||||
# Date formatting
|
# Date formatting
|
||||||
intl: ^0.19.0
|
intl: ^0.20.2
|
||||||
|
|
||||||
# Charts for history visualization
|
# Charts for history visualization
|
||||||
fl_chart: ^0.66.2
|
fl_chart: ^1.2.0
|
||||||
|
|
||||||
# Image processing for impact detection
|
# Image processing for impact detection
|
||||||
image: ^4.1.7
|
image: ^4.1.7
|
||||||
|
permission_handler: ^12.0.1
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
device_info_plus: ^13.1.0
|
device_info_plus: ^13.1.0
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
|
camera: ^0.12.0+1
|
||||||
# Machine Learning for YOLOv8
|
|
||||||
# tflite_flutter: ^0.11.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -95,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
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
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);
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,11 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
|
permission_handler_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user