88 Commits

Author SHA1 Message Date
qguillaume
a102bfd5ef fix: change adresse mail support à déterminer 2026-06-19 15:12:44 +02:00
qguillaume
89b4f433b6 feat: bouton Signaler un bug dans les paramètres (rapport copiable avec infos techniques) 2026-06-19 15:03:15 +02:00
qguillaume
9fa3a6f46d feat: accessoires en armurerie, comparateur de sessions stats, croix de centrage pixel par pixel 2026-06-19 14:40:19 +02:00
qguillaume
8165d3bab3 feat: édition rapide des accessoires créant une nouvelle arme/config pour différencier les stats 2026-06-19 14:09:46 +02:00
qguillaume
ba9975f047 fix: première arme invisible (race init DB) + recentrage des champs sous le clavier dans la modale d'édition d'arme 2026-06-19 13:41:01 +02:00
qguillaume
f7fecf0ef2 Lorsqu'on termine la session et qu'on a l'écran d'accueil > exécuter un refresh automatique pour prendre en compte cette dernière sessions dans les star de l'écran d'accueil. Actuellement il faut refresh manuellement pour que ce soit pris en compte -Dans Armurerie > Mon arme > le nb de session n'est pas remonté Ajouter un date picker pour y inscrire une date de l'entretien et qu'elle apparaisse dans la liste en bas (dans la modale ajouter un entretien) 2026-06-19 13:12:31 +02:00
qguillaume
0564bdbb48 fix: lag lors de la prise de photo 2026-06-17 20:00:14 +02:00
qguillaume
05356aaaea fix: centrage de la cible image pleine ecran respectant la prise origine au niveau des coins verts/orange 2026-06-17 19:42:13 +02:00
qguillaume
c4880ccb68 fix: cadre de centrage au ratio de la photo + bouton calibration Valider 2026-06-16 11:29:08 +02:00
qguillaume
00ae0117c5 chore: supprime le code mort signale par flutter analyze
Retire imports, champs et variables locales non utilises (14 warnings) :
- imports inutilises (app, database_helper, impact_editor, target_calibration, session_provider)
- champs write-only de capture_screen (_cropService, _scanAnimation, _isCameraInitialized, _alignmentStatus) et _movingShotId
- bloc de calcul mort dans crop_screen et variables locales (themeProvider, ny)

_filterType (history_screen) volontairement conserve : c'est une fonctionnalite de filtre inachevee, pas du code mort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:50:25 +02:00
qguillaume
1d8124c8d8 fix: clarifie les libelles de stats et corrige le calcul de l etalement moyen
- score_card: "Pourcentage" -> "Reussite", ajoute "/10" sur la moyenne
- grouping_stats: "Diametre" -> "Etalement", direction du decalage en toutes lettres (Droite, Haut-Droite...) au lieu de D/G/H/B
- statistics: corrige le bug de priorite (... ?? 0 * 100) qui affichait un etalement moyen errone (~0.9% au lieu de ~90%)
- ajoute un bouton d aide reutilisable (MetricInfoButton) expliquant chaque metrique, dont la distinction Precision (distance) / Reussite (score)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:27:10 +02:00
qguillaume
600a6bef30 fix: ratio aspect camera rectangulaire 2026-06-12 11:33:41 +02:00
qguillaume
5d2e612b9c fix: reeduction temps prise photo et enleve confusion photo en cours de traitement 2026-06-12 11:17:16 +02:00
qguillaume
4e26431be5 fix: inclinaison 2 à 3 degrés vert orange 2026-06-11 21:50:06 +02:00
qguillaume
559369c84d fix: recentrage incorrect si zoom 2026-06-11 21:21:35 +02:00
qguillaume
653aa5d5b0 fix: recentrage incorrect si loin du centre d origine 2026-06-11 21:02:47 +02:00
qguillaume
fec33a327a fix: recentrage incorrect si loin du centre d origine 2026-06-11 20:47:47 +02:00
qguillaume
8e64839fc6 fix: capture photo plus robuste 2026-06-11 20:24:57 +02:00
qguillaume
331fda0ffb fix: freeze et deformation lors de la prise de la photo (rotation) 2026-06-11 20:04:25 +02:00
qguillaume
064f5c1fe3 chore: ignorer le dossier de configuration .claude 2026-06-11 19:46:11 +02:00
qguillaume
b4226216eb feat: retrait cercle et encadre + decalage centrage de la cible pris en compte 2026-06-11 19:40:30 +02:00
qguillaume
2107f187b6 feat: adding new plotting screen fixing pan and tap 2026-06-09 00:12:10 +02:00
qguillaume
0c509bfdc3 fix: trying to avoid conflict first with tap and pin on plotting screen in progress yet 2026-06-08 23:43:33 +02:00
qguillaume
71ad670ad8 fix: trying to avoid conflict first with tap and pin on plotting screen 2026-06-08 23:27:44 +02:00
qguillaume
625810234e fix: light zoom at back 2026-06-08 23:09:47 +02:00
qguillaume
9afe3c8508 fix: opacity 80% for impacts + lower quality for pics + removing slowly zoom if you back go back 2026-06-08 22:30:51 +02:00
qguillaume
f0b15941cf fix: on revire ml kit on essaie d ameliorer la detection des cercles 2026-06-05 23:05:11 +02:00
qguillaume
9f9f9beb84 feat: target rectify service pour perfectionner la prise de photo avec detection 2026-06-05 22:27:53 +02:00
qguillaume
c49c8b8c73 fix: cadre toujours orange 2026-06-05 22:06:05 +02:00
qguillaume
61b5fcfe22 fix: ajout untrack files 2026-06-05 21:30:59 +02:00
qguillaume
f65c5e6a3f feat: object detection service 2026-06-05 21:18:48 +02:00
qguillaume
d487c98e3d fix: parallelisme service permissif et correctif capture screen 2026-06-05 20:33:04 +02:00
qguillaume
9cb37fc303 feat: construction du service parallelisme 2026-06-05 19:47:18 +02:00
qguillaume
3ae9ceebc1 fix: lenteur de l appli capture screen 2026-06-05 17:38:25 +02:00
qguillaume
251d4bb599 fix: lenteur de l appli 2026-06-05 17:18:29 +02:00
qguillaume
ccc6eb609a fix(analysis): correctif réel du zoom post prise de photo 2026-06-05 07:42:07 +02:00
qguillaume
9e0427f750 fix(analysis): normaliser les coordonnées de la cible pour annuler l'effet du zoom initial 2026-06-04 23:32:38 +02:00
qguillaume
a5d0251545 fix(camera): synchroniser le ratio de visée 3/4 avec le capteur natif et corriger le décalage du crop 2026-05-27 17:44:57 +02:00
qguillaume
730629a031 fix(crop): stabiliser le rectangle géométrique de découpe et l'enchaînement des zooms 2026-05-27 15:36:56 +02:00
qguillaume
4d2ca2c94f refactor(crop_screen): restrict rotation bounds to 15 degrees with high-precision decimal steps 2026-05-27 14:48:57 +02:00
qguillaume
ab45786d0c fix(analysis): appliquer la transformation globale sur le GestureDetector pour restaurer le drag-and-drop 2026-05-27 12:16:35 +02:00
qguillaume
95e62abe47 fix(analysis): appliquer la rotation du crop sur l'image en modes calibration et plotting 2026-05-27 12:11:00 +02:00
qguillaume
04c1204116 fix(calibration): préserver les rayons d'origine de l'IA lors du redimensionnement et corriger le reset 2026-05-27 11:56:07 +02:00
qguillaume
78c9fc96c9 feat(session): ajout du sélecteur de date et persistance dans l'historique de tir 2026-05-27 00:57:26 +02:00
qguillaume
e570bd4296 fix(calibration): application de la rotation et du centrage sur l'image globale 2026-05-27 00:23:48 +02:00
qguillaume
02c0d449e4 fix(calibration): application du crop sur l'écran de calibration 2026-05-27 00:03:29 +02:00
qguillaume
eabe7511bd feat(crop): masque opaque à coins droits pour cacher l'extérieur du cadre de découpe 2026-05-26 23:41:06 +02:00
qguillaume
620e12a1fd feat(crop): ajout de lignes de visée perpendiculaires sur toute la zone pour aligner la cible 2026-05-26 23:36:50 +02:00
qguillaume
622f2936de ui(plotting): décalage visuel de l'impact lors du glisser pour éviter de le cacher avec le doigt 2026-05-26 23:23:45 +02:00
qguillaume
814f778e6c feat(crop): remplacement de la rotation à deux doigts par une jauge précise centrée à zéro 2026-05-26 22:52:59 +02:00
qguillaume
2f493ef622 feat(setup): choix d'arme obligatoire, redirection armurerie et max balles à 50 2026-05-26 22:41:44 +02:00
qguillaume
33e1522df0 reword: Démarrer la session (home_screen) devient Nouvelle session 2026-05-26 22:29:37 +02:00
qguillaume
653264c6a1 Refactor: Amélioration globale de l'expérience de cadrage et de l'interface de capture 2026-05-26 18:09:39 +02:00
qguillaume
750732eafe Feat: Ajout de la rotation à deux doigts de l'image sur l'écran de Crop 2026-05-26 17:07:16 +02:00
qguillaume
f40bfc0ba7 Feat: Ajout du glisser-déposer par appui long pour repositionner les impacts de tir 2026-05-26 17:03:21 +02:00
qguillaume
b721583e98 Feat: Ajout du double-tap sur l'écran d'analyse pour l'édition rapide des impacts 2026-05-26 16:47:37 +02:00
qguillaume
18c01c3767 Style: Optimisation des curseurs de calibration et bridage des limites 2026-05-26 16:40:14 +02:00
qguillaume
0af6a0d8e2 Feat: Bouton de clôture de session (80/20) sur l'accueil et reset de l'espacement 2026-05-26 14:59:25 +02:00
qguillaume
fbd631c8e4 Workflow: Inversion du flux pour calibrer la cible avant d'afficher l'écran de Plotting 2026-05-25 13:52:20 +02:00
qguillaume
3088c5ee91 Refactor: Ajustement des valeurs limites de taille (0.5 à 1.1) pour synchroniser le slider et le geste tactile 2026-05-25 13:14:51 +02:00
qguillaume
fe6d52902b Refactor: Optimisation du range du slider de Taille pour améliorer la précision de calibration 2026-05-25 13:00:04 +02:00
qguillaume
48b8313be8 Feat: Ajout d'un switch pour masquer ou afficher le slider d'espacement de la cible 2026-05-25 03:12:37 +02:00
qguillaume
a9ff826246 Design: Remplacement du point rouge par une mire vert fluo interne dans le viseur 2026-05-25 02:59:46 +02:00
qguillaume
f149fe092b Fix: Passage à l'appareil photo classique et suppression de l'auto-capture 2026-05-25 02:43:31 +02:00
streaper2
774dbfcf40 feat: implement base architecture and core repositories for weapon tracking and target analysis functionality 2026-05-08 20:29:18 +02:00
streaper2
5dd58da51c Merge backend branch and restore functionalities 2026-05-08 08:34:16 +02:00
streaper2
8910a0a87e chore: remove backendia node_modules and build files from git 2026-05-08 08:33:32 +02:00
streaper2
0895b7d5bc Restore UI functionalities and clean up dependencies 2026-05-08 08:32:58 +02:00
streaper2
6892d55c65 feat: add PhotoOverlay component for visualizing and editing impact coordinates 2026-05-07 21:07:55 +02:00
qguillaume
8946955f76 feat: passage du filtre date à une sélection de période (DateRange) dans l'historique 2026-05-06 23:17:22 +02:00
qguillaume
be62669d56 feat: intégration de la vue validation image (20) 2026-05-06 23:05:13 +02:00
qguillaume
db6dd0cee5 feat: refonte des statistiques (00C) et correction ergonomique de la capture (10C) 2026-05-06 22:39:30 +02:00
qguillaume
d98f54cf93 Ajout de la gestion des permissions photos et configuration native Android/iOS 2026-05-05 18:05:49 +02:00
qguillaume
55f6210c97 feat: change titre et ajout nettoyer objectif dans capture screen 2026-05-05 16:53:29 +02:00
qguillaume
8d9b35104a Fix: Intégration du DatePicker français et mise à jour des dépendances SDK 2026-05-05 16:36:14 +02:00
qguillaume
5cc67228e4 MAJ0.1 accueil et historique 2026-05-03 16:46:05 +02:00
streaper2
cea2fab989 branch backend + ajout du serveur backend, next, et sqlite 2026-04-29 15:45:09 +02:00
streaper2
105eb1cab0 correction suppression modele yolo 2026-04-29 10:12:29 +02:00
streaper2
fba3b41f2f desactivation de tous les scripts et ia, supression du modele yolo 2026-04-28 16:52:20 +02:00
streaper2
e32833e366 preparation du modele yolo 2026-03-12 22:03:40 +01:00
streaper2
d4cb179fde correction du centrage des vecteurs de zone (cercle) pour le plotting 2026-02-21 10:22:18 +01:00
streaper2
2e81f4b69e Merge branch 'temp-fix'
detached branch a merge pour récupérer le travail
2026-02-15 22:21:31 +01:00
streaper2
de677aad7e désactivation autocalibration, et correction de distortion dans l'écran de calibration 2026-02-15 22:07:35 +01:00
streaper2
723900b860 Autocalibration de la cible sur le centre de la cible ok + petite correction de la distortion mais pas tres fonctionnel 2026-02-15 09:49:48 +01:00
streaper2
f78184d2cd nettoyage du code avec cunning 2026-02-14 22:33:35 +01:00
streaper2
7e55c52ae7 implementation de cunning pour scanner l'image et la redresser 2026-02-13 22:18:35 +01:00
streaper2
049f77ac6d ajout doc interne a l'appli 2026-02-06 13:40:01 +01:00
qguillaume
91fcd931e8 clickable home screen renamed 2026-02-03 12:16:30 +01:00
131 changed files with 21213 additions and 3797 deletions

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(flutter analyze:*)"
]
}
}

View File

@@ -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
View File

@@ -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

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

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

BIN
analysis_report.txt Normal file

Binary file not shown.

31
analyze_log.txt Normal file
View File

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

BIN
analyze_opencv.txt Normal file

Binary file not shown.

BIN
analyze_output.txt Normal file

Binary file not shown.

BIN
analyze_results_final.txt Normal file

Binary file not shown.

37
analyze_results_fixed.txt Normal file
View File

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

38
analyze_utf8.txt Normal file
View File

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

View File

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

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

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

View File

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

38
backendia/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules/
dashboard/node_modules/
# Next.js build output
dashboard/.next/
dashboard/out/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database
backendia/uploads/data/database.sqlite
*.sqlite
*.sqlite-journal
# Uploads and Exports (Ignore content but keep directories)
uploads/images/*
!uploads/images/.gitkeep
uploads/data/*
!uploads/data/.gitkeep
!uploads/data/database.sqlite
exports/*
!exports/.gitkeep
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# OS files
.DS_Store
Thumbs.db

41
backendia/dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

6
backendia/dashboard/next-env.d.ts vendored Normal file
View 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.

View File

@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '3000',
pathname: '/uploads/**',
},
],
},
};
export default nextConfig;

6616
backendia/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,104 @@
import { fetchApi } from "@/lib/api";
import { Users, Award, Camera, Clock } from "lucide-react";
interface Contributor {
wallet_hash: string;
photo_count: number;
last_upload: string;
}
export default async function ContributorsPage() {
let contributors: Contributor[] = [];
try {
const response = await fetchApi('/api/contributors');
contributors = response.contributors;
} catch (error) {
console.error("Error fetching contributors:", error);
}
return (
<div className="space-y-8">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Top Contributeurs</h2>
<p className="text-slate-400">Classement des utilisateurs selon leur contribution à l'entraînement de l'IA.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Stats Summary */}
<div className="lg:col-span-1 space-y-6">
<StatCard
title="Total Contributeurs"
value={contributors.length}
icon={<Users className="text-indigo-400" />}
/>
<StatCard
title="Total Photos"
value={contributors.reduce((acc, c) => acc + c.photo_count, 0)}
icon={<Camera className="text-emerald-400" />}
/>
</div>
{/* Table */}
<div className="lg:col-span-3 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-950/50 border-b border-slate-800">
<tr>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest">Rang</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest">Wallet Hash</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest text-center">Photos</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest text-right">Dernier Upload</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{contributors.map((c, index) => (
<tr key={c.wallet_hash} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{index < 3 ? (
<Award className={index === 0 ? "text-yellow-500" : index === 1 ? "text-slate-400" : "text-amber-700"} />
) : (
<span className="text-slate-500 font-mono w-6 text-center">#{index + 1}</span>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="font-mono text-xs text-slate-300">{c.wallet_hash}</span>
</td>
<td className="px-6 py-4 text-center">
<span className="px-2 py-1 bg-indigo-500/10 text-indigo-400 rounded-md text-xs font-bold border border-indigo-500/20">
{c.photo_count}
</span>
</td>
<td className="px-6 py-4 text-right text-xs text-slate-400 flex items-center justify-end gap-2">
<Clock size={14} />
{new Date(c.last_upload).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
{contributors.length === 0 && (
<div className="py-24 text-center text-slate-500">
Aucun contributeur enregistré pour le moment.
</div>
)}
</div>
</div>
</div>
);
}
function StatCard({ title, value, icon }: any) {
return (
<div className="bg-slate-900 border border-slate-800 p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">{title}</p>
<p className="text-3xl font-bold">{value}</p>
</div>
<div className="w-12 h-12 bg-slate-800 rounded-xl flex items-center justify-center shadow-inner">
{icon}
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import { LayoutDashboard, Users, Image as ImageIcon } from "lucide-react";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Bully IA Dashboard",
description: "Visualisation des données d'analyse de cibles",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" className="dark">
<body className={`${inter.className} bg-slate-950 text-slate-50 min-h-screen flex`}>
{/* Sidebar */}
<aside className="w-64 border-r border-slate-800 bg-slate-900/50 p-6 flex flex-col gap-8">
<div className="flex items-center gap-3 px-2">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center font-bold">B</div>
<h1 className="text-xl font-bold tracking-tight">Bully IA</h1>
</div>
<nav className="flex flex-col gap-2">
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<LayoutDashboard size={20} />
Tableau de bord
</Link>
<Link
href="/contributors"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<Users size={20} />
Contributeurs
</Link>
</nav>
<div className="mt-auto pt-6 border-t border-slate-800">
<p className="text-xs text-slate-500 px-2">v1.0.0 Alpha</p>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col">
<header className="h-16 border-b border-slate-800 flex items-center px-8 bg-slate-900/20">
<div className="flex items-center gap-2 text-sm text-slate-400">
<span>Admin</span>
<span>/</span>
<span className="text-slate-200">Dashboard</span>
</div>
</header>
<div className="flex-1 p-8 overflow-auto">
{children}
</div>
</main>
</body>
</html>
);
}

View File

@@ -0,0 +1,105 @@
import { fetchApi, API_BASE_URL } from "@/lib/api";
import DatasetToolbar from "@/components/DatasetToolbar";
import { Calendar, User, Crosshair } from "lucide-react";
import Link from "next/link";
interface PlottingData {
session_id: string;
wallet_hash: string;
timestamp: string;
plotting: {
impacts: any[];
};
}
interface PhotoData {
filename: string;
imageUrl: string;
data: PlottingData | null;
}
export default async function DashboardPage() {
let photos: PhotoData[] = [];
try {
const response = await fetchApi('/api/all-data');
photos = response.data;
} catch (error) {
console.error("Error fetching photos:", error);
}
return (
<div className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Galerie des Sessions</h2>
<p className="text-slate-400">Visualisez et gérez les données d'entraînement collectées.</p>
</div>
</div>
<DatasetToolbar />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{photos.map((photo) => (
<Link
key={photo.filename}
href={`/photo/${photo.filename}`}
className="group relative bg-slate-900 rounded-xl border border-slate-800 overflow-hidden hover:border-indigo-500/50 transition-all hover:shadow-2xl hover:shadow-indigo-500/10"
>
{/* Image Preview */}
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${API_BASE_URL}${photo.imageUrl}`}
alt={photo.filename}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
{/* Badge impacts */}
{photo.data?.plotting?.impacts && (
<div className="absolute top-3 right-3 bg-indigo-600/90 backdrop-blur-md px-2 py-1 rounded-md text-[10px] font-bold flex items-center gap-1">
<Crosshair size={12} />
{photo.data.plotting.impacts.length} IMPACTS
</div>
)}
</div>
{/* Info */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Calendar size={14} className="text-indigo-400" />
{photo.data ? new Date(photo.data.timestamp).toLocaleDateString() : 'Date inconnue'}
</div>
{photo.data ? (
<span className="text-[10px] bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20 font-bold">DATA OK</span>
) : (
<span className="text-[10px] bg-rose-500/10 text-rose-400 px-1.5 py-0.5 rounded border border-rose-500/20 font-bold">NO DATA</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-300 font-mono truncate">
<User size={14} className="text-indigo-400" />
{photo.data?.wallet_hash?.substring(0, 12) || 'Anonyme'}...
</div>
<div className="pt-2 border-t border-slate-800 flex justify-between items-center">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Session ID</span>
<span className="text-xs text-slate-300 font-mono truncate max-w-[120px]">
{photo.data?.session_id || 'N/A'}
</span>
</div>
</div>
</Link>
))}
</div>
{photos.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-slate-500 gap-4 border-2 border-dashed border-slate-800 rounded-3xl">
<Crosshair size={48} className="opacity-20" />
<p>Aucune session d'entraînement trouvée.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { fetchApi, API_BASE_URL } from "@/lib/api";
import PhotoEditor from "@/components/PhotoEditor";
import { ChevronLeft, Download } from "lucide-react";
import Link from "next/link";
export default async function PhotoDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id: encodedId } = await params;
const id = decodeURIComponent(encodedId);
let photo = null;
try {
const response = await fetchApi('/api/all-data');
photo = response.data.find((p: any) => p.filename === id);
} catch (error) {
console.error("Error fetching photo detail:", error);
}
if (!photo) {
return <div className="p-12 text-center">Session non trouvée.</div>;
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
>
<ChevronLeft size={20} />
Retour à la galerie
</Link>
<div className="flex gap-3">
<a
href={`${API_BASE_URL}/uploads/images/${id}`}
download
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Download size={18} />
Image Originale
</a>
</div>
</div>
<PhotoEditor initialPhoto={photo} />
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Download, Archive, AlertTriangle, Check, RefreshCw } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { useRouter } from "next/navigation";
export default function DatasetToolbar() {
const router = useRouter();
const [isExporting, setIsExporting] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const handleExport = async () => {
setIsExporting(true);
try {
// On ouvre simplement l'URL dans un nouvel onglet pour déclencher le téléchargement
window.open(`${API_BASE_URL}/api/export/zip`, '_blank');
} catch (error) {
console.error("Export error:", error);
} finally {
setIsExporting(false);
}
};
const handleArchive = async () => {
setIsArchiving(true);
try {
const response = await fetch(`${API_BASE_URL}/api/export/archive`, {
method: "POST"
});
if (response.ok) {
setShowArchiveConfirm(false);
router.refresh(); // Rafraîchir la galerie
} else {
alert("Erreur lors de l'archivage");
}
} catch (error) {
console.error("Archive error:", error);
alert("Erreur réseau");
} finally {
setIsArchiving(false);
}
};
return (
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between gap-6 shadow-xl shadow-indigo-500/5">
<div className="space-y-1">
<h3 className="font-bold text-lg flex items-center gap-2">
<RefreshCw size={20} className="text-indigo-400" />
Gestion du Dataset
</h3>
<p className="text-xs text-slate-500">Exportez vos sessions étiquetées en ZIP et archivez-les pour nettoyer la galerie.</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExport}
disabled={isExporting}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 px-5 py-2.5 rounded-xl text-sm font-bold transition-all hover:scale-105 active:scale-95 shadow-lg shadow-indigo-600/20"
>
<Download size={18} />
{isExporting ? "Préparation..." : "Télécharger ZIP"}
</button>
{!showArchiveConfirm ? (
<button
onClick={() => setShowArchiveConfirm(true)}
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-5 py-2.5 rounded-xl text-sm font-bold transition-all border border-slate-700"
>
<Archive size={18} />
Archiver les sessions
</button>
) : (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center gap-2 px-4 py-2 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 text-xs font-bold">
<AlertTriangle size={14} />
Vider la galerie ?
</div>
<button
onClick={handleArchive}
disabled={isArchiving}
className="bg-rose-600 hover:bg-rose-500 p-2.5 rounded-xl transition-colors disabled:opacity-50"
>
<Check size={18} className="text-white" />
</button>
<button
onClick={() => setShowArchiveConfirm(false)}
className="bg-slate-800 hover:bg-slate-700 p-2.5 rounded-xl transition-colors"
>
<Archive size={18} className="text-slate-400" />
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import PhotoOverlay from "./PhotoOverlay";
import { Info, Calendar, User, Smartphone, Cpu, Save, Edit2, X, Trash2, Check, Tag } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { useRouter } from "next/navigation";
interface PhotoEditorProps {
initialPhoto: any;
}
const PREDEFINED_LABELS = ["bullet_hole", "tear", "miss", "rebound", "other"];
export default function PhotoEditor({ initialPhoto }: PhotoEditorProps) {
const router = useRouter();
const [isEditing, setIsEditing] = useState(false);
const [photoData, setPhotoData] = useState(initialPhoto.data);
const [isSaving, setIsSaving] = useState(false);
const handleImpactsChange = (newImpacts: any[]) => {
setPhotoData({
...photoData,
plotting: {
...photoData.plotting,
impacts: newImpacts
}
});
};
const updateLabel = (index: number, label: string) => {
const newImpacts = [...photoData.plotting.impacts];
newImpacts[index] = { ...newImpacts[index], label };
handleImpactsChange(newImpacts);
};
const updateScore = (index: number, score: number) => {
const newImpacts = [...photoData.plotting.impacts];
newImpacts[index] = { ...newImpacts[index], score };
handleImpactsChange(newImpacts);
};
const handleSave = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_BASE_URL}/api/data/${initialPhoto.filename}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(photoData),
});
if (response.ok) {
setIsEditing(false);
router.refresh();
} else {
alert("Erreur lors de la sauvegarde");
}
} catch (error) {
console.error("Save error:", error);
alert("Erreur réseau");
} finally {
setIsSaving(false);
}
};
const impacts = photoData?.plotting?.impacts || [];
return (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Main View */}
<div className="xl:col-span-2 space-y-4">
<div className="flex items-center justify-between bg-slate-900/50 p-4 rounded-xl border border-slate-800">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${isEditing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'}`} />
<span className="text-sm font-medium">
{isEditing ? "Mode Édition Actif" : "Mode Visualisation"}
</span>
</div>
<div className="flex gap-2">
{isEditing ? (
<>
<button
onClick={() => {
setPhotoData(initialPhoto.data);
setIsEditing(false);
}}
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<X size={18} />
Annuler
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
<Save size={18} />
{isSaving ? "Enregistrement..." : "Enregistrer"}
</button>
</>
) : (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Edit2 size={18} />
Modifier les points
</button>
)}
</div>
</div>
<PhotoOverlay
imageUrl={`${API_BASE_URL}${initialPhoto.imageUrl}`}
impacts={impacts}
targetCorners={photoData?.plotting?.target_corners || []}
isEditing={isEditing}
onImpactsChange={handleImpactsChange}
/>
</div>
{/* Sidebar Info & Labeling */}
<div className="space-y-6">
{/* Labeling Section (Only if editing or has impacts) */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
<div className="flex items-center gap-3">
<Tag className="text-indigo-400" size={20} />
<h3 className="font-bold text-lg">Étiquetage (Impacts)</h3>
</div>
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2 py-0.5 rounded">
{impacts.length} TOTAL
</span>
</div>
<div className="space-y-3 max-h-[400px] overflow-auto pr-2 custom-scrollbar">
{impacts.map((impact: any, index: number) => (
<div key={impact.id} className="bg-slate-950 p-3 rounded-xl border border-slate-800 space-y-3 group">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-500 uppercase">Impact #{impact.id}</span>
{isEditing && (
<button
onClick={(e) => {
const newImpacts = impacts.filter((_: any, i: number) => i !== index);
handleImpactsChange(newImpacts);
}}
className="text-slate-600 hover:text-rose-500 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] text-slate-500 font-bold uppercase">Label</label>
{isEditing ? (
<select
value={impact.label}
onChange={(e) => updateLabel(index, e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
>
{PREDEFINED_LABELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
) : (
<div className="text-xs text-indigo-300 font-medium">{impact.label}</div>
)}
</div>
<div className="space-y-1">
<label className="text-[10px] text-slate-500 font-bold uppercase">Score</label>
{isEditing ? (
<input
type="number"
min="0"
max="10"
value={impact.score}
onChange={(e) => updateScore(index, parseInt(e.target.value) || 0)}
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
/>
) : (
<div className="text-xs text-emerald-400 font-bold">{impact.score}</div>
)}
</div>
</div>
</div>
))}
{impacts.length === 0 && (
<p className="text-center py-8 text-xs text-slate-500 italic">
Aucun impact détecté. {isEditing ? "Cliquez sur l'image pour en ajouter." : ""}
</p>
)}
</div>
</div>
{/* Metadata Card (Read Only) */}
<div className="bg-slate-900/50 border border-slate-800 rounded-2xl p-6 space-y-4 opacity-80">
<div className="flex items-center gap-3 pb-2 border-b border-slate-800">
<Info className="text-slate-400" size={18} />
<h3 className="font-bold text-sm text-slate-300">Métadonnées Session</h3>
</div>
<div className="space-y-3">
<InfoItem icon={<Calendar size={14} />} label="Date" value={photoData ? new Date(photoData.timestamp).toLocaleString() : 'Inconnue'} />
<InfoItem icon={<User size={14} />} label="Contributeur" value={photoData?.wallet_hash || 'Anonyme'} isMono />
<InfoItem icon={<Smartphone size={14} />} label="Appareil" value={photoData?.device_info?.model || 'Inconnu'} />
</div>
</div>
</div>
</div>
);
}
function InfoItem({ icon, label, value, isMono = false }: any) {
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 font-bold uppercase">
{icon}
{label}
</div>
<p className={`text-xs text-slate-300 ${isMono ? 'font-mono truncate' : ''}`}>
{value}
</p>
</div>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface Point {
norm_x: number;
norm_y: number;
}
interface Impact {
id: number;
label: string;
score: number;
coords: Point;
}
interface PhotoOverlayProps {
imageUrl: string;
impacts: Impact[];
targetCorners: Point[];
isEditing?: boolean;
onImpactsChange?: (impacts: Impact[]) => void;
}
export default function PhotoOverlay({
imageUrl,
impacts,
targetCorners,
isEditing = false,
onImpactsChange
}: PhotoOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [dragIndex, setDragIndex] = useState<number | null>(null);
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, []);
const handleImageClick = (e: React.MouseEvent) => {
if (!isEditing || !onImpactsChange || dragIndex !== null) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
// Calculer les coordonnées normalisées
const x = (e.clientX - rect.left) / dimensions.width;
const y = (e.clientY - rect.top) / dimensions.height;
// Créer un nouvel impact
const newImpact: Impact = {
id: impacts.length > 0 ? Math.max(...impacts.map(i => i.id)) + 1 : 1,
label: "bullet_hole",
score: 10,
coords: { norm_x: x, norm_y: y }
};
onImpactsChange([...impacts, newImpact]);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (dragIndex === null || !onImpactsChange) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = (e.clientX - rect.left) / dimensions.width;
const y = (e.clientY - rect.top) / dimensions.height;
const newImpacts = [...impacts];
newImpacts[dragIndex] = {
...newImpacts[dragIndex],
coords: { norm_x: x, norm_y: y }
};
onImpactsChange(newImpacts);
};
const deleteImpact = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
if (!isEditing || !onImpactsChange) return;
const newImpacts = impacts.filter((_, i) => i !== index);
onImpactsChange(newImpacts);
};
return (
<div
ref={containerRef}
className={`relative w-full flex items-center justify-center bg-slate-900 rounded-2xl overflow-hidden border border-slate-800 shadow-2xl min-h-[400px] ${isEditing ? 'cursor-crosshair' : ''}`}
onClick={handleImageClick}
onMouseMove={handleMouseMove}
onMouseUp={() => setDragIndex(null)}
onMouseLeave={() => setDragIndex(null)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt="Target Analysis"
className="max-w-full max-h-[80vh] object-contain select-none"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setDimensions({ width: img.clientWidth, height: img.clientHeight });
}}
/>
{dimensions.width > 0 && (
<svg
className="absolute pointer-events-none"
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
style={{ width: dimensions.width, height: dimensions.height }}
>
{/* Draw Target Box if corners exist */}
{targetCorners && targetCorners.length === 4 && (
<polygon
points={targetCorners.map(c => `${c.norm_x * dimensions.width},${c.norm_y * dimensions.height}`).join(' ')}
fill="rgba(79, 70, 229, 0.15)"
stroke="#6366f1"
strokeWidth="2"
strokeDasharray="4 2"
/>
)}
{/* Draw Impacts */}
{impacts.map((impact, index) => (
<g key={impact.id}>
{/* Glow effect */}
<circle
cx={impact.coords.norm_x * dimensions.width}
cy={impact.coords.norm_y * dimensions.height}
r={isEditing ? "12" : "8"}
fill={isEditing ? "rgba(244, 63, 94, 0.2)" : "rgba(244, 63, 94, 0.3)"}
className={isEditing ? 'pointer-events-auto cursor-move' : ''}
onMouseDown={(e) => {
if (!isEditing) return;
e.stopPropagation();
setDragIndex(index);
}}
onContextMenu={(e) => deleteImpact(e, index)}
/>
{/* Main circle */}
<circle
cx={impact.coords.norm_x * dimensions.width}
cy={impact.coords.norm_y * dimensions.height}
r="5"
fill="#f43f5e"
stroke="white"
strokeWidth="1.5"
/>
{/* Score text */}
<text
x={impact.coords.norm_x * dimensions.width}
y={impact.coords.norm_y * dimensions.height - 10}
className="text-[12px] font-bold fill-white text-center select-none"
textAnchor="middle"
style={{ textShadow: '0 0 4px rgba(0,0,0,0.9), 1px 1px 0 rgba(0,0,0,0.9)' }}
>
{impact.score}
</text>
</g>
))}
</svg>
)}
{isEditing && (
<div className="absolute bottom-4 right-4 bg-slate-950/80 backdrop-blur-md px-3 py-1.5 rounded-full text-[10px] font-bold text-indigo-400 border border-indigo-500/30">
MODE ÉDITION : CLIC POUR AJOUTER DRAG POUR DÉPLACER CLIC DROIT POUR SUPPRIMER
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
export const API_BASE_URL = 'http://127.0.0.1:3000';
export async function fetchApi(endpoint: string) {
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`Failed to fetch ${endpoint}`);
}
return res.json();
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1 @@
# Keep directory structure

View File

@@ -0,0 +1,116 @@
{
"session_id": "session_1777452202935",
"timestamp": "2026-04-29T08:43:22.943303",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3934941044560185,
"norm_y": 0.665676540798611
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.6953124999999999
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.5249565972222221
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.6759033203125,
"norm_y": 0.4786376953125
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.48608398437499994,
"norm_y": 0.5490315755208333
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5101625795717593,
"norm_y": 0.43238661024305547
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6296079282407407,
"norm_y": 0.4203152126736111
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.46386718749999994,
"norm_y": 0.48046875
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3379521122685185,
"norm_y": 0.4397786458333333
}
}
]
}
}

View File

@@ -0,0 +1,116 @@
{
"session_id": "session_1777452481252",
"timestamp": "2026-04-29T08:48:01.259705",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3934941044560185,
"norm_y": 0.665676540798611
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.6953124999999999
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.5249565972222221
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.6759033203125,
"norm_y": 0.4786376953125
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.48608398437499994,
"norm_y": 0.5490315755208333
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5101625795717593,
"norm_y": 0.43238661024305547
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6296079282407407,
"norm_y": 0.4203152126736111
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.46386718749999994,
"norm_y": 0.48046875
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3379521122685185,
"norm_y": 0.4397786458333333
}
}
]
}
}

View File

@@ -0,0 +1,44 @@
{
"session_id": "session_1777452613625",
"timestamp": "2026-04-29T08:50:13.631206",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 10,
"coords": {
"norm_x": 0.49810836226851846,
"norm_y": 0.4981011284722222
}
}
]
}
}

View File

@@ -0,0 +1,144 @@
{
"session_id": "session_1777463308809",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T11:48:28.834236",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.3666042751736111
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.396240234375
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5860903139467593,
"norm_y": 0.5999620225694443
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.33517433449074074,
"norm_y": 0.5592041015625
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.6073612919560185,
"norm_y": 0.5101047092013888
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.432373046875,
"norm_y": 0.46663411458333337
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.45181297019675926,
"norm_y": 0.5564236111111112
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 10,
"coords": {
"norm_x": 0.5221860532407407,
"norm_y": 0.48792860243055547
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.6564335575810184,
"norm_y": 0.7536349826388888
}
},
{
"id": 10,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.4712528935185185,
"norm_y": 0.7018229166666666
}
},
{
"id": 11,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.3981327763310185,
"norm_y": 0.7018229166666666
}
},
{
"id": 12,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.48144531249999994,
"norm_y": 0.8212483723958334
}
}
]
}
}

View File

@@ -0,0 +1,117 @@
{
"session_id": "session_1777463703756",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T11:55:03.765285",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.45178222656249994
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6518247251157407,
"norm_y": 0.3638237847222222
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5490415219907407,
"norm_y": 0.66845703125
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.405517578125,
"norm_y": 0.6758490668402777
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.7268066406249999,
"norm_y": 0.6758490668402777
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5471806278935185,
"norm_y": 0.529568142361111
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.44720504195601846,
"norm_y": 0.5980631510416666
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.4221462673611111
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5203251591435185,
"norm_y": 0.3638237847222222
}
}
]
}
}

View File

@@ -0,0 +1,117 @@
{
"session_id": "session_1777464156234",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T12:02:36.255849",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.45178222656249994
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6518247251157407,
"norm_y": 0.3638237847222222
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5490415219907407,
"norm_y": 0.66845703125
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.405517578125,
"norm_y": 0.6758490668402777
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.7268066406249999,
"norm_y": 0.6758490668402777
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5471806278935185,
"norm_y": 0.529568142361111
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.44720504195601846,
"norm_y": 0.5980631510416666
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.4221462673611111
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5203251591435185,
"norm_y": 0.3638237847222222
}
}
]
}
}

View File

@@ -0,0 +1,180 @@
{
"session_id": "session_1777464607256",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T12:10:07.260954",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.7388509114583333
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.3184814453125,
"norm_y": 0.6489935980902778
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3110966435185185,
"norm_y": 0.529568142361111
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.4416097005208333
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.34252929687499994
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.32589699074074074,
"norm_y": 0.2332763671875
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.19624837239583331
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.23049587673611108
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6907045717592593,
"norm_y": 0.3546006944444444
}
},
{
"id": 10,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.699951171875,
"norm_y": 0.48046875
}
},
{
"id": 11,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6268310546874999,
"norm_y": 0.5416395399305556
}
},
{
"id": 12,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.49072265624999994,
"norm_y": 0.5610351562499999
}
},
{
"id": 13,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.4203495732060185,
"norm_y": 0.536960177951389
}
},
{
"id": 14,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5370180483217593,
"norm_y": 0.6879204644097222
}
},
{
"id": 15,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.6175537109375,
"norm_y": 0.7601453993055556
}
},
{
"id": 16,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.7027280454282406,
"norm_y": 0.8406439887152777
}
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1 @@
# Keep directory structure

View File

@@ -0,0 +1,270 @@
{
"session_id": "session_1777553535029",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-30T12:52:15.048830",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.17681929976851848,
"norm_y": 0.8851318359375
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.17681929976851848,
"norm_y": 0.7721489800347222
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.17959617332175923,
"norm_y": 0.6888020833333334
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.17959617332175923,
"norm_y": 0.6342095269097222
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.17959617332175923,
"norm_y": 0.560153537326389
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.17959617332175923,
"norm_y": 0.5138346354166666
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.1814579716435185,
"norm_y": 0.45273166232638884
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.18701171875,
"norm_y": 0.3878987630208333
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.18887351707175926,
"norm_y": 0.2925482855902778
}
},
{
"id": 10,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.2036747685185185,
"norm_y": 0.20085991753472218
}
},
{
"id": 11,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.27035499855324074,
"norm_y": 0.16200086805555558
}
},
{
"id": 12,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.37588523582175926,
"norm_y": 0.1546088324652778
}
},
{
"id": 13,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.46292136863425926,
"norm_y": 0.1518283420138889
}
},
{
"id": 14,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.6814579716435185,
"norm_y": 0.1499294704861111
}
},
{
"id": 15,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.5509033203125,
"norm_y": 0.1546088324652778
}
},
{
"id": 16,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.6156919126157407,
"norm_y": 0.1546088324652778
}
},
{
"id": 17,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.7703251591435185,
"norm_y": 0.2360568576388889
}
},
{
"id": 18,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.7110595703125,
"norm_y": 0.3536512586805555
}
},
{
"id": 19,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6027524594907407,
"norm_y": 0.4129231770833333
}
},
{
"id": 20,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.4823911313657407,
"norm_y": 0.4304877387152777
}
},
{
"id": 21,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.39627097800925926,
"norm_y": 0.43509928385416663
}
},
{
"id": 22,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.289794921875,
"norm_y": 0.43238661024305547
}
},
{
"id": 23,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.7166440610532407,
"norm_y": 0.7693684895833333
}
},
{
"id": 24,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.7314453125,
"norm_y": 0.7008734809027778
}
},
{
"id": 25,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.7879331235532407,
"norm_y": 0.7601453993055556
}
},
{
"id": 26,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.736083984375,
"norm_y": 0.828640407986111
}
}
]
}
}

View File

@@ -0,0 +1 @@
# Keep directory structure

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1682
backendia/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
backendia/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "backendia",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js",
"dashboard": "cd dashboard && npm run dev -- -p 3001",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"adm-zip": "^0.5.17",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"multer": "^2.1.1",
"sqlite3": "^6.0.1"
}
}

346
backendia/server.js Normal file
View File

@@ -0,0 +1,346 @@
const express = require('express');
const multer = require('multer');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
const AdmZip = require('adm-zip');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Arborescence demandée
const uploadDir = path.join(__dirname, 'uploads');
const imagesDir = path.join(uploadDir, 'images');
const dataDir = path.join(uploadDir, 'data');
const exportsDir = path.join(__dirname, 'exports');
// S'assurer que les dossiers existent
[uploadDir, imagesDir, dataDir, exportsDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
// Initialisation de la base de données SQLite
const dbPath = path.join(dataDir, 'database.sqlite');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Erreur de connexion à SQLite:', err.message);
} else {
console.log('Connecté à la base de données SQLite.');
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
wallet_hash TEXT PRIMARY KEY,
photo_count INTEGER DEFAULT 0,
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
}
});
// Configuration de multer pour le stockage des fichiers
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, imagesDir); // Les photos vont dans uploads/images/
},
filename: function (req, file, cb) {
// Créer un nom de fichier unique tout en gardant l'extension originale
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 15 * 1024 * 1024 // Limite de taille: 15 MB
},
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
// Accepter si c'est un mimetype image OU si l'extension est celle d'une image
if (file.mimetype.startsWith('image/') || allowedExts.includes(ext) || file.mimetype === 'application/octet-stream') {
cb(null, true);
} else {
console.error(`Fichier rejeté: mimetype=${file.mimetype}, extension=${ext}`);
cb(new Error('Seules les images sont autorisées !'), false);
}
}
});
// Routes de base
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'Le serveur backend IA fonctionne' });
});
// Route pour l'upload de photo + données JSON
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
app.post('/api/upload', upload.single('photo'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Aucune photo fournie' });
}
let plottingData = {};
if (req.body.plotting) {
try {
plottingData = JSON.parse(req.body.plotting);
} catch (e) {
console.error("Erreur parsing JSON:", e);
return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' });
}
}
// Nom de base sans l'extension
const baseFilename = path.parse(req.file.filename).name;
const jsonFilename = `${baseFilename}.json`;
const jsonFilePath = path.join(dataDir, jsonFilename);
// Sauvegarde du JSON dans uploads/data/
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
// Mise à jour de la BDD si on a un wallet_hash
const walletHash = plottingData.wallet_hash;
if (walletHash) {
db.run(`
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
VALUES (?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(wallet_hash) DO UPDATE SET
photo_count = photo_count + 1,
last_upload = CURRENT_TIMESTAMP
`, [walletHash], function(err) {
if (err) {
console.error("Erreur lors de la mise à jour des stats :", err);
} else {
console.log(`Stats mises à jour pour le wallet_hash ${walletHash.substring(0, 8)}...`);
}
});
}
console.log(`Données reçues et sauvegardées:`);
console.log(`- Image: uploads/images/${req.file.filename}`);
console.log(`- JSON : uploads/data/${jsonFilename}`);
res.status(200).json({
message: 'Photo et données uploadées avec succès',
file: {
filename: req.file.filename,
url: `/uploads/images/${req.file.filename}`,
size: req.file.size
},
data: {
filename: jsonFilename
}
});
} catch (error) {
console.error('Erreur lors de l\'upload:', error);
res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' });
}
});
// Route pour récupérer toutes les photos disponibles
app.get('/api/photos', (req, res) => {
try {
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
}
const photos = files.map(file => ({
filename: file,
url: `/uploads/images/${file}`
}));
res.status(200).json({ photos });
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Route pour récupérer les statistiques d'un wallet_hash
app.get('/api/stats/:wallet_hash', (req, res) => {
const walletHash = req.params.wallet_hash;
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
}
if (row) {
res.json({ status: 'ok', stats: row });
} else {
res.json({ status: 'ok', stats: { photo_count: 0, last_upload: null } });
}
});
});
// Route pour récupérer TOUS les contributeurs
app.get('/api/contributors', (req, res) => {
db.all('SELECT * FROM user_stats ORDER BY photo_count DESC', [], (err, rows) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des contributeurs' });
}
res.json({ contributors: rows });
});
});
// Route pour récupérer toutes les données (Photos + JSON)
app.get('/api/all-data', (req, res) => {
try {
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
}
const allData = files.map(file => {
const baseName = path.parse(file).name;
const jsonPath = path.join(dataDir, `${baseName}.json`);
let plotting = null;
if (fs.existsSync(jsonPath)) {
try {
plotting = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} catch (e) {
console.error(`Erreur lecture JSON pour ${file}`);
}
}
return {
filename: file,
imageUrl: `/uploads/images/${file}`,
data: plotting
};
});
res.status(200).json({ data: allData });
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Route pour mettre à jour les données JSON d'une session
app.put('/api/data/:filename', (req, res) => {
const filename = req.params.filename;
const baseName = path.parse(filename).name;
const jsonPath = path.join(dataDir, `${baseName}.json`);
try {
if (!fs.existsSync(jsonPath)) {
return res.status(404).json({ error: 'Fichier JSON non trouvé' });
}
// On écrase le fichier avec les nouvelles données reçues
const newData = req.body;
fs.writeFileSync(jsonPath, JSON.stringify(newData, null, 2));
console.log(`Données mises à jour pour : ${baseName}.json`);
res.status(200).json({ message: 'Données mises à jour avec succès' });
} catch (error) {
console.error('Erreur lors de la mise à jour du JSON:', error);
res.status(500).json({ error: 'Erreur serveur lors de la mise à jour' });
}
});
// Route pour exporter toutes les données en ZIP
app.get('/api/export/zip', (req, res) => {
try {
const zip = new AdmZip();
// Ajouter les dossiers au ZIP
if (fs.existsSync(imagesDir)) zip.addLocalFolder(imagesDir, 'images');
if (fs.existsSync(dataDir)) zip.addLocalFolder(dataDir, 'data');
const zipFilename = `dataset_export_${Date.now()}.zip`;
const zipPath = path.join(exportsDir, zipFilename);
// Sauvegarder sur le disque
zip.writeZip(zipPath);
// Envoyer le fichier au client
res.download(zipPath, zipFilename, (err) => {
if (err) console.error("Erreur lors de l'envoi du ZIP:", err);
// Optionnel: supprimer le ZIP local après envoi si vous voulez gagner de la place
// fs.unlinkSync(zipPath);
});
} catch (error) {
console.error('Erreur lors de la création du ZIP:', error);
res.status(500).json({ error: 'Erreur lors de la création de l\'archive' });
}
});
// Route pour archiver et vider les dossiers uploads
app.post('/api/export/archive', (req, res) => {
try {
const archiveName = `archive_${new Date().toISOString().replace(/[:.]/g, '-')}`;
const archivePath = path.join(exportsDir, archiveName);
if (!fs.existsSync(archivePath)) {
fs.mkdirSync(archivePath, { recursive: true });
}
// Fonction pour déplacer les fichiers
const moveFiles = (source, destSubDir) => {
const dest = path.join(archivePath, destSubDir);
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
const files = fs.readdirSync(source);
files.forEach(file => {
// Ne pas déplacer la base de données sqlite
if (file === 'database.sqlite') return;
const oldPath = path.join(source, file);
const newPath = path.join(dest, file);
try {
fs.renameSync(oldPath, newPath);
} catch (e) {
// Fallback si renameSync échoue (ex: disque différent)
fs.copyFileSync(oldPath, newPath);
fs.unlinkSync(oldPath);
}
});
};
moveFiles(imagesDir, 'images');
moveFiles(dataDir, 'data');
res.json({ message: 'Données archivées avec succès', archive: archiveName });
} catch (error) {
console.error('Erreur lors de l\'archivage:', error);
res.status(500).json({ error: 'Erreur lors de l\'archivage des données' });
}
});
// Servir les fichiers statiques (pour pouvoir accéder aux photos via URL)
app.use('/uploads/images', express.static(imagesDir));
// Gestion des erreurs
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Le fichier est trop volumineux (limite 15MB)' });
}
}
console.error(err.stack);
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
});
// Démarrer le serveur
app.listen(PORT, () => {
console.log(`=================================`);
console.log(`Serveur Backend IA démarré`);
console.log(`Port: ${PORT}`);
console.log(`Dossiers:`);
console.log(` - Images: ${imagesDir}`);
console.log(` - Data : ${dataDir}`);
console.log(` - Export: ${exportsDir}`);
console.log(`=================================`);
});

Binary file not shown.

View File

@@ -0,0 +1,90 @@
{
"session_id": "session_1777554006515",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-30T13:00:06.519693",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.267578125,
"norm_y": 0.5554741753472222
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5712583188657407,
"norm_y": 0.3592800564236111
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.7555239076967593,
"norm_y": 0.5703938802083333
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.6666259765625,
"norm_y": 0.6759168836805556
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5490415219907407,
"norm_y": 0.5212944878472222
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.4897768373842592,
"norm_y": 0.39352756076388895
}
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

20
build_log.txt Normal file
View File

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

26
docs/README.md Normal file
View File

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

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

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

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

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

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

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

View File

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

View File

@@ -1,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(),
);
},
); );
} }
} }

View File

@@ -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;

View 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';
}
}
}

View 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'),
),
],
),
);
}
}

View File

@@ -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,106 +347,52 @@ 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,
); );
// First, check if there are orphaned shots (with empty session_id)
// and only one session - if so, assign them to that session
if (sessionMaps.length == 1) {
final orphanedShots = await db.query(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [''],
);
if (orphanedShots.isNotEmpty) {
await db.update(
AppConstants.shotsTable,
{'session_id': sessionMaps.first['id']},
where: 'session_id = ?',
whereArgs: [''],
);
}
}
final sessions = <Session>[]; final sessions = <Session>[];
for (final sessionMap in sessionMaps) { for (final sessionMap in sessionMaps) {
final sessionId = sessionMap['id'] as String; final sessionId = sessionMap['id'] as String;
final session = await getSession(sessionId);
final shotMaps = await db.query( if (session != null) {
AppConstants.shotsTable, sessions.add(session);
where: 'session_id = ?', }
whereArgs: [sessionId],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
sessions.add(Session.fromMap(sessionMap, shots));
} }
return sessions; return sessions;
} }
Future<int> updateSession(Session session) async {
final db = await database;
return await db.transaction((txn) async {
await txn.update(
AppConstants.sessionsTable,
session.toMap(),
where: 'id = ?',
whereArgs: [session.id],
);
// Delete existing shots and insert new ones
await txn.delete(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [session.id],
);
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 {
final db = await database; final db = await database;
return await db.delete( return await db.delete(
@@ -229,12 +414,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 +431,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;
} }
} }

View 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?,
);
}
}

View File

@@ -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)';
} }
} }

View File

@@ -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;
} }
} }

View 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
View 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?,
);
}
}

View File

@@ -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);
}
} }

View File

@@ -7,9 +7,9 @@ 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';
@@ -17,6 +17,8 @@ import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart'; import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
import '../../services/distortion_correction_service.dart'; import '../../services/distortion_correction_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/ai_export_service.dart';
enum AnalysisState { initial, loading, success, error } enum AnalysisState { initial, loading, success, error }
@@ -26,6 +28,7 @@ class AnalysisProvider extends ChangeNotifier {
final GroupingAnalyzerService _groupingAnalyzerService; final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository; final SessionRepository _sessionRepository;
final DistortionCorrectionService _distortionService; final DistortionCorrectionService _distortionService;
final OpenCVTargetService _opencvTargetService;
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
AnalysisProvider({ AnalysisProvider({
@@ -34,21 +37,27 @@ class AnalysisProvider extends ChangeNotifier {
required GroupingAnalyzerService groupingAnalyzerService, required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository, required SessionRepository sessionRepository,
DistortionCorrectionService? distortionService, DistortionCorrectionService? distortionService,
OpenCVTargetService? opencvTargetService,
}) : _detectionService = detectionService, }) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService, _scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService, _groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository, _sessionRepository = sessionRepository,
_distortionService = distortionService ?? DistortionCorrectionService(); _distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
AnalysisState _state = AnalysisState.initial; AnalysisState _state = AnalysisState.initial;
String? _errorMessage; String? _errorMessage;
String? _imagePath; String? _imagePath;
TargetType? _targetType; TargetType? _targetType;
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
double _cropRotation = 0.0;
// Target detection results // Target detection results
double _targetCenterX = 0.5; double _targetCenterX = 0.5;
double _targetCenterY = 0.5; double _targetCenterY = 0.5;
double _targetRadius = 0.4; double _targetRadius = 0.4;
double _targetInnerRadius = 0.04;
int _ringCount = 10; int _ringCount = 10;
List<double>? _ringRadii; // Individual ring radii multipliers List<double>? _ringRadii; // Individual ring radii multipliers
double _imageAspectRatio = 1.0; // width / height double _imageAspectRatio = 1.0; // width / height
@@ -76,9 +85,11 @@ class AnalysisProvider extends ChangeNotifier {
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;
double get targetInnerRadius => _targetInnerRadius;
int get ringCount => _ringCount; int get ringCount => _ringCount;
List<double>? get ringRadii => List<double>? get ringRadii =>
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null; _ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
@@ -102,18 +113,25 @@ class AnalysisProvider extends ChangeNotifier {
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon) /// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath => String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null _distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath ? _correctedImagePath
: _imagePath; : _imagePath;
/// Modifie et mémorise la rotation de l'image pour le Plotting
void setCropRotation(double rotation) {
_cropRotation = rotation;
notifyListeners();
}
/// Analyze an image /// Analyze an image
/// ///
/// [autoAnalyze] determines if we should run automatic detection immediately. /// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set. /// 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, bool autoAnalyze = true,
}) async { Offset? manualCenter,
}) async {
_state = AnalysisState.loading; _state = AnalysisState.loading;
_imagePath = imagePath; _imagePath = imagePath;
_targetType = targetType; _targetType = targetType;
@@ -131,9 +149,10 @@ class AnalysisProvider extends ChangeNotifier {
if (!autoAnalyze) { if (!autoAnalyze) {
// Just setup default values without running detection // Just setup default values without running detection
_targetCenterX = 0.5; _targetCenterX = manualCenter?.dx ?? 0.5;
_targetCenterY = 0.5; _targetCenterY = manualCenter?.dy ?? 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04;
// Initialize empty shots list // Initialize empty shots list
_shots = []; _shots = [];
@@ -143,8 +162,10 @@ class AnalysisProvider extends ChangeNotifier {
return; return;
} }
// Detect target and impacts final result = await _detectionService.detectTargetAsync(
final result = _detectionService.detectTarget(imagePath, targetType); imagePath,
targetType,
);
if (!result.success) { if (!result.success) {
_state = AnalysisState.error; _state = AnalysisState.error;
@@ -156,6 +177,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = result.centerX; _targetCenterX = result.centerX;
_targetCenterY = result.centerY; _targetCenterY = result.centerY;
_targetRadius = result.radius; _targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts // Create shots from detected impacts
_shots = result.impacts.map((impact) { _shots = result.impacts.map((impact) {
@@ -164,7 +186,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: impact.suggestedScore, score: impact.suggestedScore,
sessionId: '', // Will be set when saving analysisId: '',
); );
}).toList(); }).toList();
@@ -186,7 +208,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();
@@ -215,6 +237,16 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Update a shot's score manually
void updateShotScore(String shotId, int newScore) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
_shots[index] = _shots[index].copyWith(score: newScore);
_recalculateScores();
notifyListeners();
}
/// Auto-detect impacts using image processing /// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({ Future<int> autoDetectImpacts({
int darkThreshold = 80, int darkThreshold = 80,
@@ -256,7 +288,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: score, score: score,
sessionId: '', analysisId: '',
); );
_shots.add(shot); _shots.add(shot);
} }
@@ -269,14 +301,6 @@ class AnalysisProvider extends ChangeNotifier {
} }
/// Auto-detect impacts using OpenCV (Hough Circles + Contours) /// 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({ Future<int> autoDetectImpactsWithOpenCV({
double cannyThreshold1 = 50, double cannyThreshold1 = 50,
double cannyThreshold2 = 150, double cannyThreshold2 = 150,
@@ -285,6 +309,8 @@ class AnalysisProvider extends ChangeNotifier {
double param2 = 30, double param2 = 30,
int minRadius = 5, int minRadius = 5,
int maxRadius = 50, int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5, int blurSize = 5,
bool useContourDetection = true, bool useContourDetection = true,
double minCircularity = 0.6, double minCircularity = 0.6,
@@ -331,7 +357,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: score, score: score,
sessionId: '', analysisId: '',
); );
_shots.add(shot); _shots.add(shot);
} }
@@ -361,15 +387,15 @@ class AnalysisProvider extends ChangeNotifier {
final detectedImpacts = _detectionService final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences( .detectImpactsWithOpenCVFromReferences(
_imagePath!, _imagePath!,
_targetType!, _targetType!,
_targetCenterX, _targetCenterX,
_targetCenterY, _targetCenterY,
_targetRadius, _targetRadius,
_ringCount, _ringCount,
references, references,
tolerance: tolerance, tolerance: tolerance,
); );
if (clearExisting) { if (clearExisting) {
_shots.clear(); _shots.clear();
@@ -383,7 +409,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: score, score: score,
sessionId: '', analysisId: '',
); );
_shots.add(shot); _shots.add(shot);
} }
@@ -398,7 +424,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a reference impact for calibrated detection /// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) { void addReferenceImpact(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: '');
_referenceImpacts.add(shot); _referenceImpacts.add(shot);
notifyListeners(); notifyListeners();
} }
@@ -468,7 +494,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: score, score: score,
sessionId: '', analysisId: '',
); );
_shots.add(shot); _shots.add(shot);
} }
@@ -482,21 +508,26 @@ class AnalysisProvider extends ChangeNotifier {
/// Adjust target position /// Adjust target position
void adjustTargetPosition( void adjustTargetPosition(
double centerX, double centerX,
double centerY, double centerY,
double radius, { double innerRadius,
int? ringCount, double radius, {
List<double>? ringRadii, int? ringCount,
}) { List<double>? ringRadii,
_targetCenterX = centerX; double zoomScale = 1.0,
_targetCenterY = centerY; Offset offset = Offset.zero,
_targetRadius = radius; }) {
_targetCenterX = (centerX - offset.dx) / zoomScale;
_targetCenterY = (centerY - offset.dy) / zoomScale;
_targetRadius = radius / zoomScale;
_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) {
@@ -508,6 +539,42 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio = 1.0;
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
debugPrint('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle /// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() { void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration( _distortionParams = _distortionService.calculateDistortionFromCalibration(
@@ -548,7 +615,6 @@ class AnalysisProvider extends ChangeNotifier {
} }
} }
/* version deux a tester*/
/// Calcule ET applique la correction pour un feedback immédiat /// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async { Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel) // 1. Calcul des paramètres (votre code actuel)
@@ -564,18 +630,18 @@ class AnalysisProvider extends ChangeNotifier {
// 3. Application immédiate de la transformation (méthode asynchrone) // 3. Application immédiate de la transformation (méthode asynchrone)
await applyDistortionCorrection(); await applyDistortionCorrection();
} else { } else {
notifyListeners(); // On prévient quand même si pas de correction notifyListeners();
} }
} }
Future<void> runFullDistortionWorkflow() async { Future<void> runFullDistortionWorkflow() async {
_state = AnalysisState.loading; // Affiche un spinner sur votre UI _state = AnalysisState.loading;
notifyListeners(); notifyListeners();
try { try {
calculateDistortion(); // Calcule les paramètres calculateDistortion();
await applyDistortionCorrection(); // Génère le fichier corrigé await applyDistortionCorrection();
_distortionCorrectionEnabled = true; // Active l'affichage _distortionCorrectionEnabled = true;
_state = AnalysisState.success; _state = AnalysisState.success;
} catch (e) { } catch (e) {
_errorMessage = "Erreur de rendu : $e"; _errorMessage = "Erreur de rendu : $e";
@@ -584,7 +650,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); 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) {
@@ -629,16 +694,55 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
} }
/// Exporte l'image et le json vers le backend IA
Future<bool> exportToAiBackend() async {
if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
notifyListeners();
return false;
}
final service = AiExportService();
_state = AnalysisState.loading;
notifyListeners();
final success = await service.exportData(
imagePath: _imagePath!,
sessionId: 'export',
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
shots: _shots,
);
_state = AnalysisState.success;
if (!success) {
_errorMessage = "Échec de l'export vers le serveur IA.";
}
notifyListeners();
return success;
}
/// 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,
@@ -649,11 +753,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
@@ -662,9 +796,11 @@ 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;
_targetInnerRadius = 0.04;
_ringCount = 10; _ringCount = 10;
_ringRadii = null; _ringRadii = null;
_imageAspectRatio = 1.0; _imageAspectRatio = 1.0;
@@ -678,4 +814,23 @@ class AnalysisProvider extends ChangeNotifier {
_correctedImagePath = 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

View File

@@ -0,0 +1,283 @@
/// É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';
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) {
_showShotDetails(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) =>
_showShotDetails(context, provider, shot),
),
),
],
),
),
),
),
),
],
),
);
}
void _showShotDetails(
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),
),
),
),
],
),
],
),
),
);
}
}

View File

@@ -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) {

View File

@@ -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)}%',
), ),
], ],

View File

@@ -4,7 +4,6 @@
/// Les anneaux sont répartis proportionnellement. /// Les anneaux sont répartis proportionnellement.
library; library;
import 'dart:math' as math;
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';
@@ -13,16 +12,26 @@ class TargetCalibration extends StatefulWidget {
final double initialCenterX; final double initialCenterX;
final double initialCenterY; final double initialCenterY;
final double initialRadius; final double initialRadius;
final double initialInnerRadius;
final int initialRingCount; final int initialRingCount;
final TargetType targetType; final TargetType targetType;
final List<double>? initialRingRadii; final List<double>? initialRingRadii;
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? ringRadii}) onCalibrationChanged; final Function(
double centerX,
double centerY,
double innerRadius,
double radius,
int ringCount, {
List<double>? ringRadii,
})
onCalibrationChanged;
const TargetCalibration({ const TargetCalibration({
super.key, super.key,
required this.initialCenterX, required this.initialCenterX,
required this.initialCenterY, required this.initialCenterY,
required this.initialRadius, required this.initialRadius,
required this.initialInnerRadius,
this.initialRingCount = 10, this.initialRingCount = 10,
required this.targetType, required this.targetType,
this.initialRingRadii, this.initialRingRadii,
@@ -30,18 +39,29 @@ 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;
late double _innerRadius;
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 _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() {
@@ -49,28 +69,74 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_centerX = widget.initialCenterX; _centerX = widget.initialCenterX;
_centerY = widget.initialCenterY; _centerY = widget.initialCenterY;
_radius = widget.initialRadius; _radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
// 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 _ringRadii = List.generate(_ringCount, (i) {
_ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount); if (_ringCount <= 1) return 1.0;
return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
});
} }
} }
@override @override
void didUpdateWidget(TargetCalibration oldWidget) { void didUpdateWidget(TargetCalibration oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX && !_isDraggingCenter) {
_centerX = widget.initialCenterX;
}
if (widget.initialCenterY != oldWidget.initialCenterY && !_isDraggingCenter) {
_centerY = widget.initialCenterY;
}
if (widget.initialRingCount != oldWidget.initialRingCount) { if (widget.initialRingCount != oldWidget.initialRingCount) {
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); shouldReinit = true;
} }
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) { if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) {
_radius = widget.initialRadius; _radius = widget.initialRadius;
shouldReinit = true;
}
if (widget.initialInnerRadius != oldWidget.initialInnerRadius && !_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius;
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii && widget.initialRingRadii != null) {
_originalRingRadii = List.from(widget.initialRingRadii!);
shouldReinit = true;
}
if (shouldReinit) {
_initRingRadii();
} }
} }
@@ -80,82 +146,285 @@ 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) {
ringCount: _ringCount, setState(() {
ringRadii: _ringRadii, _isDraggingCenter = true;
targetType: widget.targetType, });
isDraggingCenter: _isDraggingCenter, }
isDraggingRadius: _isDraggingRadius, },
onScaleUpdate: (details) => _onScaleUpdate(details, size),
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 radius handle (on the right edge of the outermost circle) ),
final minDim = math.min(size.width, size.height); child: Row(
final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0); mainAxisSize: MainAxisSize.min,
final radiusHandleX = _centerX + outerRadius * minDim / size.width; children: [
final radiusHandleY = _centerY; _buildSignLabel(''),
final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0)); const SizedBox(width: 12),
Column(
if (distToCenter < 0.05) { mainAxisSize: MainAxisSize.min,
setState(() { children: [
_isDraggingCenter = true; _buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
}); Row(
} else if (distToRadiusHandle < 0.05) { mainAxisSize: MainAxisSize.min,
setState(() { children: [
_isDraggingRadius = true; _buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
}); const SizedBox(width: 40),
} else if (distToCenter < _radius + 0.02) { _buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
// Tapping inside the target - move center ],
setState(() { ),
_isDraggingCenter = true; _buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
}); ],
} ),
const SizedBox(width: 12),
_buildSignLabel('+'),
],
),
);
} }
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),
);
}
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
margin: const EdgeInsets.all(2),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 28),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
);
}
void _notifyChange() {
widget.onCalibrationChanged(
_centerX,
_centerY,
_innerRadius,
_radius,
_ringCount,
ringRadii: _ringRadii,
);
}
void _moveCenterByPixels(double dx, double dy, Size size) {
setState(() { setState(() {
if (_isDraggingCenter) { _centerX = _centerX + (dx / size.width);
// Move center _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; _centerX = _centerX + deltaX;
_centerY = _centerY + deltaY; _centerY = _centerY + deltaY;
} else if (_isDraggingRadius) {
// Adjust outer radius (scales all rings proportionally)
final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(0.05, 3.0);
} }
}); });
_notifyChange();
widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii);
} }
void _onPanEnd() { void _onScaleEnd() {
setState(() { setState(() {
_isDraggingCenter = false; _isDraggingCenter = false;
_isDraggingRadius = false; _isDraggingRadius = false;
_isDraggingInnerRadius = false;
}); });
} }
@@ -170,21 +439,25 @@ class _CalibrationPainter extends CustomPainter {
final double centerX; final double centerX;
final double centerY; final double centerY;
final double radius; final double radius;
final double innerRadius;
final int ringCount; final int ringCount;
final List<double> ringRadii; final List<double> ringRadii;
final TargetType targetType; final TargetType targetType;
final bool isDraggingCenter; final bool isDraggingCenter;
final bool isDraggingRadius; final bool isDraggingRadius;
final bool isDraggingInnerRadius;
_CalibrationPainter({ _CalibrationPainter({
required this.centerX, required this.centerX,
required this.centerY, required this.centerY,
required this.radius, required this.radius,
required this.innerRadius,
required this.ringCount, required this.ringCount,
required this.ringRadii, required this.ringRadii,
required this.targetType, required this.targetType,
required this.isDraggingCenter, required this.isDraggingCenter,
required this.isDraggingRadius, required this.isDraggingRadius,
required this.isDraggingInnerRadius,
}); });
@override @override
@@ -199,18 +472,17 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx); _drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
} }
// Draw center handle if (isDraggingCenter) {
final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
}
_drawCenterHandle(canvas, centerPx); _drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw instructions
_drawInstructions(canvas, size); _drawInstructions(canvas, size);
} }
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) { void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
// Generate colors for zones
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;
@@ -231,9 +503,8 @@ 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 ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius; final zoneRadius = baseRadius * ringRadius;
@@ -243,22 +514,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 ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final prevRingRadius = i > 0 final textSpanRatio = i > 0 ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) : 0.0;
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) final zoneRadius = baseRadius * (ringRadius + textSpanRatio) / 2;
: 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;
@@ -268,14 +531,11 @@ class _CalibrationPainter extends CustomPainter {
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.9),
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
shadows: const [ shadows: const [Shadow(color: Colors.black, blurRadius: 2)],
Shadow(color: Colors.black, blurRadius: 2),
],
), ),
); );
textPainter.layout(); textPainter.layout();
// Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2; final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) { if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY)); textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
@@ -284,143 +544,49 @@ class _CalibrationPainter extends CustomPainter {
} }
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) { void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
// Simplified silhouette zones
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2; final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now)
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()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint); canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint); canvas.drawLine(Offset(center.dx + 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 - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint); canvas.drawLine(Offset(center.dx, center.dy + 4), 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 outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0;
final actualRadius = baseRadius * outerRingRadius;
final actualHandleX = center.dx + actualRadius;
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: 'RAYON',
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 _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 ||
innerRadius != oldDelegate.innerRadius ||
ringCount != oldDelegate.ringCount || ringCount != oldDelegate.ringCount ||
isDraggingCenter != oldDelegate.isDraggingCenter || isDraggingCenter != oldDelegate.isDraggingCenter ||
isDraggingRadius != oldDelegate.isDraggingRadius || isDraggingRadius != oldDelegate.isDraggingRadius ||
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
ringRadii != oldDelegate.ringRadii; ringRadii != oldDelegate.ringRadii;
} }
} }

View File

@@ -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 détectés, le cercle de groupement
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout /// et les impacts de référence. Gère uniquement la SÉLECTION d'impacts existants
/// d'impacts et la sélection d'impacts existants. /// (tap sur un impact). 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';
@@ -25,6 +26,7 @@ class TargetOverlay extends StatelessWidget {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
const TargetOverlay({ const TargetOverlay({
super.key, super.key,
@@ -42,79 +44,74 @@ class TargetOverlay extends StatelessWidget {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
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, referenceImpacts: referenceImpacts,
referenceImpacts: referenceImpacts, zoomScale: zoomScale,
zoomScale: zoomScale, showRings: showRings,
), ),
child: Stack( child: LayoutBuilder(
builder: (context, constraints) {
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(),
), );
), },
); ),
}, ),
); );
} }
} }
@@ -132,6 +129,7 @@ class _TargetOverlayPainter extends CustomPainter {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
_TargetOverlayPainter({ _TargetOverlayPainter({
required this.shots, required this.shots,
@@ -146,12 +144,15 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
// Draw target center indicator // Draw target center indicator
_drawTargetCenter(canvas, size); if (showRings) {
_drawTargetCenter(canvas, size);
}
// Draw grouping circle // Draw grouping circle
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) { if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
@@ -204,8 +205,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;
@@ -285,16 +286,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);
@@ -371,6 +373,7 @@ class _TargetOverlayPainter extends CustomPainter {
groupingCenterY != oldDelegate.groupingCenterY || groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter || groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts || referenceImpacts != oldDelegate.referenceImpacts ||
zoomScale != oldDelegate.zoomScale; zoomScale != oldDelegate.zoomScale ||
showRings != oldDelegate.showRings;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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,118 +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.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 4),
Text(
'Glissez à un doigt pour déplacer, pincez pour zoomer',
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
),
],
),
),
// 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 = math.min(constraints.maxWidth, constraints.maxHeight) * 0.85; // visée = toute la zone visible → aucun bord noir autour du cadre.
_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, 1.0), ..scale(_scale, _scale)
..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,
), ),
@@ -150,22 +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( child: Stack(
cropSize: _cropSize, children: [
showGrid: true, Center(
child: Container(
width: double.infinity,
height: 1.5,
color: const Color(0xFF00FF00).withOpacity(0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withOpacity(0.6),
),
),
],
), ),
), ),
), ),
// 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),
),
), ),
], ],
), ),
@@ -177,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) {
@@ -206,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)),
);
}
}

View File

@@ -13,20 +13,13 @@ class CropOverlay extends StatelessWidget {
/// Afficher la grille des tiers /// Afficher la grille des tiers
final bool showGrid; final bool showGrid;
const CropOverlay({ const CropOverlay({super.key, required this.cropSize, this.showGrid = true});
super.key,
required this.cropSize,
this.showGrid = true,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomPaint( return CustomPaint(
size: Size.infinite, size: Size.infinite,
painter: _CropOverlayPainter( painter: _CropOverlayPainter(cropSize: cropSize, showGrid: showGrid),
cropSize: cropSize,
showGrid: showGrid,
),
); );
} }
} }
@@ -35,10 +28,7 @@ class _CropOverlayPainter extends CustomPainter {
final double cropSize; final double cropSize;
final bool showGrid; final bool showGrid;
_CropOverlayPainter({ _CropOverlayPainter({required this.cropSize, required this.showGrid});
required this.cropSize,
required this.showGrid,
});
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
@@ -77,6 +67,9 @@ class _CropOverlayPainter extends CustomPainter {
if (showGrid) { if (showGrid) {
_drawGrid(canvas, cropRect); _drawGrid(canvas, cropRect);
} }
// Dessiner le point central (croix interne vert fluo)
_drawCenterPoint(canvas, cropRect);
} }
void _drawCorners(Canvas canvas, Rect rect) { void _drawCorners(Canvas canvas, Rect rect) {
@@ -171,8 +164,45 @@ class _CropOverlayPainter extends CustomPainter {
); );
} }
void _drawCenterPoint(Canvas canvas, Rect rect) {
// Peinture de la croix interne (Vert Fluo)
final centerPaint = Paint()
..color = const Color(0xFF00FF00)
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
// Cercle extérieur (Vert Fluo fin)
final circlePaint = Paint()
..color = const Color(0xFF00FF00).withValues(alpha: 0.9)
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
const circleRadius = 10.0; // Augmenté légèrement à 10 pour que la croix respire
final centerX = rect.center.dx;
final centerY = rect.center.dy;
// 1. Dessiner le cercle de contour
canvas.drawCircle(rect.center, circleRadius, circlePaint);
// 2. Dessiner la croix UNIQUEMENT à l'intérieur du cercle
// Ligne horizontale (de gauche à droite, reste à l'intérieur)
canvas.drawLine(
Offset(centerX - circleRadius, centerY),
Offset(centerX + circleRadius, centerY),
centerPaint,
);
// Ligne verticale (de haut en bas, reste à l'intérieur)
canvas.drawLine(
Offset(centerX, centerY - circleRadius),
Offset(centerX, centerY + circleRadius),
centerPaint,
);
}
@override @override
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) { bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid; return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;
} }
} }

View File

@@ -0,0 +1,590 @@
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(context),
),
],
),
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(context),
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(context),
),
],
),
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(context, 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(BuildContext context) async {
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>(
value: 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,
);
final repository = context.read<SessionRepository>();
await repository.updateWeapon(updatedWeapon);
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(BuildContext context) async {
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) 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) {
if (mounted) {
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 repository = context.read<SessionRepository>();
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(BuildContext context) async {
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>(
value: 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) {
final repository = context.read<SessionRepository>();
await repository.addMaintenanceEntry(
weaponId: _weapon.id,
type: selectedType,
description: descController.text,
roundsSinceLast: _totalRounds,
date: selectedDate,
);
_loadData();
}
}
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async {
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) {
final repository = context.read<SessionRepository>();
await repository.deleteMaintenanceEntry(entry.id);
_loadData();
}
}
}

View File

@@ -0,0 +1,277 @@
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(context),
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(context),
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(context, 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(BuildContext context) async {
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>(
value: 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) {
final repository = context.read<SessionRepository>();
await repository.addWeapon(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
);
_loadWeapons();
}
}
void _confirmDelete(BuildContext context, Weapon weapon) async {
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) {
final repository = context.read<SessionRepository>();
await repository.deleteWeapon(weapon.id);
_loadWeapons();
}
}
}

Some files were not shown because too many files have changed in this diff Show More