77 Commits

Author SHA1 Message Date
qguillaume
2b904205a0 fix: reset espacement calibration + refresh auto historique 2026-07-04 11:22:50 +02:00
qguillaume
cec9838e9b refactor: perf getAllSessions, zéro warning analyze, docs à jour
- getAllSessions : élimine le N+1 (une requête par session + par analyse)
  au profit de 3 requêtes groupées (sessions, analyses IN, shots IN),
  découpées en paquets de 500 pour rester sous la limite SQLite. Assemblage
  en mémoire via maps — accueil/historique/stats ne ralentiront plus au fil
  des mois
- flutter analyze : 28 -> 0 problème. APIs dépréciées migrées (withOpacity
  -> withValues, scale -> scaleByDouble, DropdownButtonFormField.value ->
  initialValue, dialogBackgroundColor -> dialogTheme, activeColor ->
  activeThumbColor), use_build_context_synchronously corrigés dans
  l'armurerie (repository capturé avant await + gardes mounted), print ->
  debugPrint, identifiants TopLeft/... -> lowerCamelCase, blocs if, champ final
- _showShotDetails dédupliqué : bottom sheet extraite dans le widget partagé
  shot_details_sheet.dart, utilisée par l'écran d'analyse et l'éditeur d'impacts
- CLAUDE.md : sections Features réécrites (retrait détection auto/références,
  ajout capture/plotting manuel) ; description pubspec renseignée

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:40:25 +02:00
qguillaume
8d5641c0a3 chore: retire les fichiers parasites du dépôt
- Supprime les 7 logs d'analyse à la racine (analyze_*.txt,
  analysis_report.txt)
- Dé-tracke les données du backend commitées par erreur avant les
  règles gitignore : exports (zips, archives), uploads (photos
  utilisateurs, database.sqlite) — les fichiers restent sur le disque
- Corrige backendia/.gitignore : retire la négation
  !uploads/data/database.sqlite qui ré-incluait la base de données
  utilisateurs, et la règle au chemin erroné au-dessus
- Ajoute les .gitkeep manquants (uploads/images, uploads/data) pour
  préserver la structure des dossiers sur un clone frais

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:27:35 +02:00
qguillaume
a3167fc2ba fix: filtre historique, fuite mémoire OpenCV, export IA, tests
- Historique : le filtre par type de cible est désormais appliqué au
  chargement ; correction du piège PopupMenuItem(value: null) qui
  empêchait l'option « Tous » de réinitialiser le filtre ; icône
  colorée quand un filtre est actif
- OpenCV : libération des Mat natifs (img, gray, blurred, circles)
  dans un finally — detectTarget tourne toutes les secondes pendant
  l'aperçu caméra et faisait grimper la mémoire native en continu
- Export IA : distance, arme et id de session réels transmis depuis
  SessionProvider au lieu des placeholders (25 m / "Unknown")
- Tests : remplacement du test widget cassé (BullyApp sans providers)
  par 14 tests qui passent — calcul de score concentrique (centre,
  hors cible, ratio d'image, anneaux personnalisés), agrégation des
  scores, analyse de groupement, et rendu du widget StatsCard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:16:49 +02:00
qguillaume
972bfbe0e9 chore: suppression du code mort (détection auto, distorsion, ML Kit)
- Supprime 5 services inatteignables depuis l'UI (~3 000 lignes) :
  distortion_correction, image_processing, target_detection,
  opencv_impact_detection, target_rectify
- AnalysisProvider allégé (835 -> ~360 lignes) : retrait de la détection
  par références, de la détection auto d'impacts, du workflow distorsion
  et du doublon moveShot
- Retire les dépendances inutilisées google_mlkit_object_detection et
  google_mlkit_document_scanner du pubspec
- Le bouton ↻ du Plotting efface désormais tous les impacts en un clic
  (clearShots) sans relancer la détection auto ni toucher la calibration
- Nettoie les paramètres morts de TargetOverlay (referenceImpacts,
  onAddShot), le flag _isSelectingReferences, _buildActionButtons vide
  et le résidu _detectionTimer de capture_screen
- Supprime le dossier tests/ (brouillons d'expérimentation OpenCV)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:04:09 +02:00
qguillaume
f65f65112c feat: bouton nuage d'export vers le backend IA dans l'écran d'analyse 2026-06-29 10:16:16 +02:00
qguillaume
beb20074f2 fix: filtre par choix de gun, switch sticky 2026-06-20 21:13:26 +02:00
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
streaper2
cea2fab989 branch backend + ajout du serveur backend, next, et sqlite 2026-04-29 15:45:09 +02:00
99 changed files with 17999 additions and 6314 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/
.swiftpm/
migrate_working_dir/
.claude/
# IntelliJ related
*.iml
@@ -43,3 +44,15 @@ app.*.map.json
/android/app/debug
/android/app/profile
/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

View File

@@ -41,10 +41,12 @@ flutter test --coverage
## Features
### Analyse de cibles de tir
### Capture de cibles de tir
- Support de cibles concentriques (anneaux) et silhouettes
- Chargement d'images de cibles depuis la galerie ou la caméra
- Détection automatique du centre et du rayon de la cible
- Aperçu caméra avec aide au cadrage : détection OpenCV de la cible (mire)
et indicateur de parallélisme par accéléromètre (pitch/roll)
- Écran de centrage/recadrage : rotation fine, déplacement pixel par pixel
### Calibration des cibles
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
@@ -52,14 +54,11 @@ flutter test --coverage
- Slider global pour redimensionner tous les anneaux proportionnellement
- Visualisation en temps réel des zones de score
### Détection d'impacts
- **Ajout manuel** : cliquer sur l'image pour placer un impact
- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables
- Seuil de luminosité
- Taille min/max des impacts
- Circularité minimale
- Ratio de remplissage (distingue les trous pleins des cercles vides)
- **Détection par références** : sélectionner 2-4 impacts manuellement, l'algorithme apprend leurs caractéristiques et détecte les impacts similaires
### Placement des impacts
- **Éditeur d'impacts plein écran** : tap pour ajouter, tap sur un impact pour
l'éditer (score/suppression), appui long pour déplacer, pincer pour zoomer
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de plotting
efface tous les impacts sans toucher à la calibration
### Calcul des scores
- Score automatique basé sur la position de l'impact dans les zones

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

36
backendia/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# 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 (contient des données utilisateurs — jamais dans git)
*.sqlite
*.sqlite-journal
# Uploads and Exports (Ignore content but keep directories)
uploads/images/*
!uploads/images/.gitkeep
uploads/data/*
!uploads/data/.gitkeep
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

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(`=================================`);
});

View File

View File

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/theme/theme_provider.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 {
@@ -8,23 +10,26 @@ class BullyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Bully - Analyse de Cibles',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('fr', 'FR'), // Français
],
locale: const Locale('fr', 'FR'), // Force l'interface en français
home: const HomeScreen(),
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: 'Bully - Analyse de Cibles',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
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
static const String databaseName = 'bully_targets.db';
static const int databaseVersion = 1;
static const int databaseVersion = 6;
// Tables
static const String sessionsTable = 'sessions';
static const String targetAnalysesTable = 'target_analyses';
static const String shotsTable = 'shots';
static const String weaponsTable = 'weapons';
static const String maintenanceTable = 'weapon_maintenance';
// Image processing
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:path/path.dart';
import '../models/session.dart';
import '../models/target_analysis.dart';
import '../models/shot.dart';
import '../models/weapon.dart';
import '../models/maintenance.dart';
import '../../core/constants/app_constants.dart';
class DatabaseHelper {
static DatabaseHelper? _instance;
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();
@@ -16,7 +25,9 @@ class DatabaseHelper {
}
Future<Database> get database async {
_database ??= await _initDatabase();
if (_database != null) return _database!;
_initFuture ??= _initDatabase();
_database = await _initFuture!;
return _database!;
}
@@ -36,6 +47,19 @@ class DatabaseHelper {
await db.execute('''
CREATE TABLE ${AppConstants.sessionsTable} (
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,
image_path TEXT NOT NULL,
total_score INTEGER NOT NULL,
@@ -46,7 +70,8 @@ class DatabaseHelper {
notes TEXT,
target_center_x 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,
y REAL NOT NULL,
score INTEGER NOT NULL,
session_id TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
analysis_id TEXT NOT NULL,
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
)
''');
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('''
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 {
// 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
Future<int> insertSession(Session session) async {
final db = await database;
return await db.transaction((txn) async {
// INTERCEPTION ET FIX DE LA DATE ICI :
// On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet
final Map<String, dynamic> sessionMap = Map.from(session.toMap());
sessionMap['created_at'] = session.createdAt.toIso8601String();
await txn.insert(
AppConstants.sessionsTable,
session.toMap(),
sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct !
conflictAlgorithm: ConflictAlgorithm.replace,
);
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,
);
for (final analysis in session.analyses) {
await insertTargetAnalysisTx(txn, analysis, session.id);
}
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 {
final db = await database;
final sessionMaps = await db.query(
@@ -108,104 +347,104 @@ class DatabaseHelper {
if (sessionMaps.isEmpty) return null;
final shotMaps = await db.query(
AppConstants.shotsTable,
final analysisMaps = await db.query(
AppConstants.targetAnalysesTable,
where: 'session_id = ?',
whereArgs: [id],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
return Session.fromMap(sessionMaps.first, shots);
final analyses = <TargetAnalysis>[];
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({
String? targetType,
int? limit,
int? offset,
}) async {
final db = await database;
String? whereClause;
List<dynamic>? whereArgs;
if (targetType != null) {
whereClause = 'target_type = ?';
whereArgs = [targetType];
}
final sessionMaps = await db.query(
AppConstants.sessionsTable,
where: whereClause,
whereArgs: whereArgs,
orderBy: 'created_at DESC',
limit: limit,
offset: offset,
);
if (sessionMaps.isEmpty) return [];
// 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: [''],
);
}
// 3 requêtes groupées au lieu d'une cascade par session puis par analyse
// (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
// ce qui ralentissait l'accueil/historique/stats au fil des mois.
final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
final analysisMaps = await _queryIn(
db,
AppConstants.targetAnalysesTable,
'session_id',
sessionIds,
);
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
final shotMaps = await _queryIn(
db,
AppConstants.shotsTable,
'analysis_id',
analysisIds,
);
final shotsByAnalysis = <String, List<Shot>>{};
for (final map in shotMaps) {
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
.add(Shot.fromMap(map));
}
final sessions = <Session>[];
for (final sessionMap in sessionMaps) {
final sessionId = sessionMap['id'] as String;
final shotMaps = await db.query(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [sessionId],
final analysesBySession = <String, List<TargetAnalysis>>{};
for (final map in analysisMaps) {
final analysis = TargetAnalysis.fromMap(
map,
shotsByAnalysis[map['id'] as String] ?? [],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
sessions.add(Session.fromMap(sessionMap, shots));
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
}
return sessions;
return sessionMaps
.map((m) =>
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
.toList();
}
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],
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
Future<List<Map<String, Object?>>> _queryIn(
Database db,
String table,
String column,
List<String> values,
) async {
if (values.isEmpty) return [];
const chunkSize = 500;
final results = <Map<String, Object?>>[];
for (var i = 0; i < values.length; i += chunkSize) {
final chunk = values.sublist(
i,
i + chunkSize > values.length ? values.length : i + chunkSize,
);
// 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;
});
final placeholders = List.filled(chunk.length, '?').join(',');
results.addAll(await db.query(
table,
where: '$column IN ($placeholders)',
whereArgs: chunk,
));
}
return results;
}
Future<int> deleteSession(String id) async {
@@ -229,12 +468,12 @@ class DatabaseHelper {
) ?? 0;
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;
final bestScore = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}',
'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}',
),
) ?? 0;
@@ -246,9 +485,135 @@ class DatabaseHelper {
};
}
// Weapon operations
Future<int> insertWeapon(Weapon weapon) async {
final db = await database;
return await db.insert(
AppConstants.weaponsTable,
weapon.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<Weapon>> getAllWeapons() async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(AppConstants.weaponsTable, orderBy: 'name ASC');
return List.generate(maps.length, (i) => Weapon.fromMap(maps[i]));
}
Future<Weapon?> getWeapon(String id) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
AppConstants.weaponsTable,
where: 'id = ?',
whereArgs: [id],
);
if (maps.isEmpty) return null;
return Weapon.fromMap(maps.first);
}
Future<int> updateWeapon(Weapon weapon) async {
final db = await database;
// Start a transaction to ensure both updates succeed
return await db.transaction((txn) async {
// 1. Update weapon in armory
final count = await txn.update(
AppConstants.weaponsTable,
weapon.toMap(),
where: 'id = ?',
whereArgs: [weapon.id],
);
// 2. Propagate name change to all sessions linked to this weapon
await txn.update(
AppConstants.sessionsTable,
{'weapon': weapon.displayName},
where: 'weapon_id = ?',
whereArgs: [weapon.id],
);
return count;
});
}
Future<int> deleteWeapon(String id) async {
final db = await database;
return await db.transaction((txn) async {
// 1. Unlink sessions from this weapon but KEEP the name (gravé à jamais)
await txn.update(
AppConstants.sessionsTable,
{'weapon_id': null},
where: 'weapon_id = ?',
whereArgs: [id],
);
// 2. Delete the weapon record
return await txn.delete(
AppConstants.weaponsTable,
where: 'id = ?',
whereArgs: [id],
);
});
}
// Weapon statistics & Maintenance
Future<int> getRoundsFiredForWeapon(String weaponId) async {
final db = await database;
final result = await db.rawQuery('''
SELECT COUNT(s.id) as count
FROM ${AppConstants.shotsTable} s
JOIN ${AppConstants.targetAnalysesTable} ta ON s.analysis_id = ta.id
JOIN ${AppConstants.sessionsTable} sess ON ta.session_id = sess.id
WHERE sess.weapon_id = ?
''', [weaponId]);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> getSessionCountForWeapon(String weaponId) async {
final db = await database;
final result = await db.rawQuery('''
SELECT COUNT(id) as count
FROM ${AppConstants.sessionsTable}
WHERE weapon_id = ?
''', [weaponId]);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> insertMaintenance(MaintenanceEntry entry) async {
final db = await database;
return await db.insert(
AppConstants.maintenanceTable,
entry.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
final db = await database;
final maps = await db.query(
AppConstants.maintenanceTable,
where: 'weapon_id = ?',
orderBy: 'date DESC',
whereArgs: [weaponId],
);
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
}
Future<int> deleteMaintenance(String id) async {
final db = await database;
return await db.delete(
AppConstants.maintenanceTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> close() async {
final db = await database;
await db.close();
_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_type.dart';
import 'target_analysis.dart';
class Session {
final String id;
final TargetType targetType;
final String imagePath;
final List<Shot> shots;
final int totalScore;
final double? groupingDiameter;
final double? groupingCenterX;
final double? groupingCenterY;
final String weapon;
final String? weaponId;
final int maxShotsPerTarget;
final List<TargetAnalysis> analyses;
final DateTime createdAt;
final String? notes;
// Target detection data
final double? targetCenterX;
final double? targetCenterY;
final double? targetRadius;
final int distance; // Shooting distance in meters
Session({
required this.id,
required this.targetType,
required this.imagePath,
required this.shots,
required this.totalScore,
this.groupingDiameter,
this.groupingCenterX,
this.groupingCenterY,
required this.weapon,
this.weaponId,
required this.maxShotsPerTarget,
required this.analyses,
required this.createdAt,
this.notes,
this.targetCenterX,
this.targetCenterY,
this.targetRadius,
this.distance = 25, // Default distance
});
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({
String? id,
TargetType? targetType,
String? imagePath,
List<Shot>? shots,
int? totalScore,
double? groupingDiameter,
double? groupingCenterX,
double? groupingCenterY,
String? weapon,
String? weaponId,
int? maxShotsPerTarget,
List<TargetAnalysis>? analyses,
DateTime? createdAt,
String? notes,
double? targetCenterX,
double? targetCenterY,
double? targetRadius,
int? distance,
}) {
return Session(
id: id ?? this.id,
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,
weapon: weapon ?? this.weapon,
weaponId: weaponId ?? this.weaponId,
maxShotsPerTarget: maxShotsPerTarget ?? this.maxShotsPerTarget,
analyses: analyses ?? this.analyses,
createdAt: createdAt ?? this.createdAt,
notes: notes ?? this.notes,
targetCenterX: targetCenterX ?? this.targetCenterX,
targetCenterY: targetCenterY ?? this.targetCenterY,
targetRadius: targetRadius ?? this.targetRadius,
distance: distance ?? this.distance,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'target_type': targetType.name,
'image_path': imagePath,
'total_score': totalScore,
'grouping_diameter': groupingDiameter,
'grouping_center_x': groupingCenterX,
'grouping_center_y': groupingCenterY,
'weapon': weapon,
'weapon_id': weaponId,
'max_shots_per_target': maxShotsPerTarget,
'created_at': createdAt.toIso8601String(),
'notes': notes,
'target_center_x': targetCenterX,
'target_center_y': targetCenterY,
'target_radius': targetRadius,
'distance': distance,
};
}
factory Session.fromMap(Map<String, dynamic> map, List<Shot> shots) {
factory Session.fromMap(Map<String, dynamic> map, List<TargetAnalysis> analyses) {
return Session(
id: map['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?,
weapon: map['weapon'] as String? ?? 'Inconnue',
weaponId: map['weapon_id'] as String?,
maxShotsPerTarget: map['max_shots_per_target'] as int? ?? 5,
analyses: analyses,
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?,
distance: map['distance'] as int? ?? 25,
);
}
@override
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 y; // Relative position (0.0 - 1.0)
final int score;
final String sessionId;
final String analysisId;
Shot({
required this.id,
required this.x,
required this.y,
required this.score,
required this.sessionId,
required this.analysisId,
});
Shot copyWith({
@@ -18,14 +18,14 @@ class Shot {
double? x,
double? y,
int? score,
String? sessionId,
String? analysisId,
}) {
return Shot(
id: id ?? this.id,
x: x ?? this.x,
y: y ?? this.y,
score: score ?? this.score,
sessionId: sessionId ?? this.sessionId,
analysisId: analysisId ?? this.analysisId,
);
}
@@ -35,7 +35,7 @@ class Shot {
'x': x,
'y': y,
'score': score,
'session_id': sessionId,
'analysis_id': analysisId,
};
}
@@ -45,13 +45,13 @@ class Shot {
x: (map['x'] as num).toDouble(),
y: (map['y'] as num).toDouble(),
score: map['score'] as int,
sessionId: map['session_id'] as String,
analysisId: map['analysis_id'] as String? ?? map['session_id'] as String? ?? '',
);
}
@override
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
@@ -62,11 +62,11 @@ class Shot {
other.x == x &&
other.y == y &&
other.score == score &&
other.sessionId == sessionId;
other.analysisId == analysisId;
}
@override
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 '../database/database_helper.dart';
import '../models/session.dart';
import '../models/target_analysis.dart';
import '../models/shot.dart';
import '../models/target_type.dart';
import '../models/weapon.dart';
import '../models/maintenance.dart';
class SessionRepository {
final DatabaseHelper _databaseHelper;
@@ -18,8 +20,38 @@ class SessionRepository {
_uuid = uuid ?? const Uuid();
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 sessionId,
required dynamic targetType, // TargetType
required List<Shot> shots,
required int totalScore,
double? groupingDiameter,
@@ -33,8 +65,9 @@ class SessionRepository {
// Copy image to app documents directory
final savedImagePath = await _saveImage(imagePath);
final session = Session(
return TargetAnalysis(
id: _uuid.v4(),
sessionId: sessionId,
targetType: targetType,
imagePath: savedImagePath,
shots: shots,
@@ -48,9 +81,6 @@ class SessionRepository {
targetCenterY: targetCenterY,
targetRadius: targetRadius,
);
await _databaseHelper.insertSession(session);
return session;
}
Future<String> _saveImage(String sourcePath) async {
@@ -75,28 +105,23 @@ class SessionRepository {
}
Future<List<Session>> getAllSessions({
TargetType? targetType,
int? limit,
int? offset,
}) async {
return await _databaseHelper.getAllSessions(
targetType: targetType?.name,
limit: limit,
offset: offset,
);
}
Future<void> updateSession(Session session) async {
await _databaseHelper.updateSession(session);
}
Future<void> deleteSession(String id) async {
final session = await getSession(id);
if (session != null) {
// Delete the image file
final imageFile = File(session.imagePath);
if (await imageFile.exists()) {
await imageFile.delete();
for (final analysis in session.analyses) {
final imageFile = File(analysis.imagePath);
if (await imageFile.exists()) {
await imageFile.delete();
}
}
}
await _databaseHelper.deleteSession(id);
@@ -106,7 +131,84 @@ class SessionRepository {
return await _databaseHelper.getStatistics();
}
String generateShotId() {
String generateId() {
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

@@ -1,55 +1,47 @@
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
///
/// Gère le workflow complet d'analyse : chargement d'image, détection de cible,
/// gestion des impacts (manuels et automatiques), calcul des scores,
/// analyse de groupement et sauvegarde des sessions.
/// Gère le workflow complet d'analyse : chargement d'image, gestion des
/// impacts placés manuellement, calcul des scores, analyse de groupement
/// et sauvegarde des sessions.
library;
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/target_type.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../../services/distortion_correction_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/ai_export_service.dart';
enum AnalysisState { initial, loading, success, error }
class AnalysisProvider extends ChangeNotifier {
final TargetDetectionService _detectionService;
final ScoreCalculatorService _scoreCalculatorService;
final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository;
final DistortionCorrectionService _distortionService;
final OpenCVTargetService _opencvTargetService;
final Uuid _uuid = const Uuid();
AnalysisProvider({
required TargetDetectionService detectionService,
required ScoreCalculatorService scoreCalculatorService,
required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository,
DistortionCorrectionService? distortionService,
OpenCVTargetService? opencvTargetService,
}) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository,
_distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
}) : _scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository;
AnalysisState _state = AnalysisState.initial;
String? _errorMessage;
String? _imagePath;
TargetType? _targetType;
// Target detection results
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
double _cropRotation = 0.0;
// Target calibration
double _targetCenterX = 0.5;
double _targetCenterY = 0.5;
double _targetRadius = 0.4;
@@ -67,20 +59,12 @@ class AnalysisProvider extends ChangeNotifier {
// Grouping results
GroupingResult? _groupingResult;
// Reference-based detection
List<Shot> _referenceImpacts = [];
ImpactCharacteristics? _learnedCharacteristics;
// Distortion correction
bool _distortionCorrectionEnabled = false;
DistortionParameters? _distortionParams;
String? _correctedImagePath;
// Getters
AnalysisState get state => _state;
String? get errorMessage => _errorMessage;
String? get imagePath => _imagePath;
TargetType? get targetType => _targetType;
double get cropRotation => _cropRotation; // Getter pour le Plotting
double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius;
@@ -95,30 +79,19 @@ class AnalysisProvider extends ChangeNotifier {
int get totalScore => _scoreResult?.totalScore ?? 0;
int get shotCount => _shots.length;
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
// Distortion correction getters
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
DistortionParameters? get distortionParams => _distortionParams;
String? get correctedImagePath => _correctedImagePath;
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
/// Modifie et mémorise la rotation de l'image pour le Plotting
void setCropRotation(double rotation) {
_cropRotation = rotation;
notifyListeners();
}
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath
: _imagePath;
/// Analyze an image
///
/// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set.
/// Charge l'image et initialise les paramètres de cible par défaut.
/// Le placement des impacts et la calibration se font ensuite manuellement.
Future<void> analyzeImage(
String imagePath,
TargetType targetType, {
bool autoAnalyze = true,
Offset? manualCenter,
}) async {
_state = AnalysisState.loading;
_imagePath = imagePath;
@@ -135,52 +108,12 @@ class AnalysisProvider extends ChangeNotifier {
_imageAspectRatio = frame.image.width / frame.image.height;
frame.image.dispose();
if (!autoAnalyze) {
// Just setup default values without running detection
_targetCenterX = 0.5;
_targetCenterY = 0.5;
_targetRadius = 0.4;
_targetInnerRadius = 0.04;
_targetCenterX = manualCenter?.dx ?? 0.5;
_targetCenterY = manualCenter?.dy ?? 0.5;
_targetRadius = 0.4;
_targetInnerRadius = 0.04;
// Initialize empty shots list
_shots = [];
_state = AnalysisState.success;
notifyListeners();
return;
}
// Detect target and impacts
final result = _detectionService.detectTarget(imagePath, targetType);
if (!result.success) {
_state = AnalysisState.error;
_errorMessage = result.errorMessage;
notifyListeners();
return;
}
_targetCenterX = result.centerX;
_targetCenterY = result.centerY;
_targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts
_shots = result.impacts.map((impact) {
return Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
sessionId: '', // Will be set when saving
);
}).toList();
// Calculate scores
_recalculateScores();
// Calculate grouping
_recalculateGrouping();
_shots = [];
_state = AnalysisState.success;
notifyListeners();
@@ -194,7 +127,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a manual shot
void addShot(double x, double 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);
_recalculateScores();
@@ -202,6 +135,15 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Plotting).
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
void clearShots() {
_shots.clear();
_recalculateScores();
_recalculateGrouping();
notifyListeners();
}
/// Remove a shot
void removeShot(String shotId) {
_shots.removeWhere((shot) => shot.id == shotId);
@@ -210,19 +152,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Move a shot to a new position
void moveShot(String shotId, double newX, double newY) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
final newScore = _calculateShotScore(newX, newY);
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
_recalculateScores();
_recalculateGrouping();
notifyListeners();
}
/// Update a shot's score manually
void updateShotScore(String shotId, int newScore) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
@@ -233,273 +162,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({
int darkThreshold = 80,
int minImpactSize = 20,
int maxImpactSize = 500,
double minCircularity = 0.6,
double minFillRatio = 0.5,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = ImpactDetectionSettings(
darkThreshold: darkThreshold,
minImpactSize: minImpactSize,
maxImpactSize: maxImpactSize,
minCircularity: minCircularity,
minFillRatio: minFillRatio,
);
final detectedImpacts = _detectionService.detectImpactsOnly(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
///
/// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes
/// de compilation. Cette méthode retourne 0 (aucun impact détecté).
/// Utiliser autoDetectImpacts() à la place.
///
/// Utilise les algorithmes OpenCV pour une détection plus robuste:
/// - Transformation de Hough pour détecter les cercles
/// - Analyse de contours avec filtrage par circularité
Future<int> autoDetectImpactsWithOpenCV({
double cannyThreshold1 = 50,
double cannyThreshold2 = 150,
double minDist = 20,
double param1 = 100,
double param2 = 30,
int minRadius = 5,
int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5,
bool useContourDetection = true,
double minCircularity = 0.6,
double minContourArea = 50,
double maxContourArea = 5000,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = OpenCVDetectionSettings(
cannyThreshold1: cannyThreshold1,
cannyThreshold2: cannyThreshold2,
minDist: minDist,
param1: param1,
param2: param2,
minRadius: minRadius,
maxRadius: maxRadius,
blurSize: blurSize,
useContourDetection: useContourDetection,
minCircularity: minCircularity,
minContourArea: minContourArea,
maxContourArea: maxContourArea,
);
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings: settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Detect impacts with OpenCV using reference points
Future<int> detectFromReferencesWithOpenCV({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_referenceImpacts.length < 2) {
return 0;
}
// Convertir les références
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
references,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
_referenceImpacts.add(shot);
notifyListeners();
}
/// Remove a reference impact
void removeReferenceImpact(String shotId) {
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
_learnedCharacteristics = null;
notifyListeners();
}
/// Clear all reference impacts
void clearReferenceImpacts() {
_referenceImpacts.clear();
_learnedCharacteristics = null;
notifyListeners();
}
/// Learn characteristics from reference impacts
bool learnFromReferences() {
if (_imagePath == null || _referenceImpacts.length < 2) return false;
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
_imagePath!,
references,
);
notifyListeners();
return _learnedCharacteristics != null;
}
/// Auto-detect impacts using learned reference characteristics
Future<int> detectFromReferences({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_learnedCharacteristics == null) {
return 0;
}
final detectedImpacts = _detectionService.detectImpactsFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
_learnedCharacteristics!,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Adjust target position
void adjustTargetPosition(
double centerX,
@@ -508,17 +170,20 @@ class AnalysisProvider extends ChangeNotifier {
double radius, {
int? ringCount,
List<double>? ringRadii,
double zoomScale = 1.0,
Offset offset = Offset.zero,
}) {
_targetCenterX = centerX;
_targetCenterY = centerY;
_targetInnerRadius = innerRadius;
_targetRadius = radius;
_targetCenterX = (centerX - offset.dx) / zoomScale;
_targetCenterY = (centerY - offset.dy) / zoomScale;
_targetRadius = radius / zoomScale;
_targetInnerRadius = innerRadius / zoomScale;
if (ringCount != null) {
_ringCount = ringCount;
}
if (ringRadii != null) {
_ringRadii = ringRadii;
}
// CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement !
_ringRadii = ringRadii;
// Recalculate all shot scores based on new target position
_shots = _shots.map((shot) {
@@ -530,121 +195,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio =
1.0; // The corrected image is always square (side x side)
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
print('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
notifyListeners();
}
/// Applique la correction de distorsion à l'image
/// Crée une nouvelle image corrigée et la sauvegarde
Future<void> applyDistortionCorrection() async {
if (_imagePath == null || _distortionParams == null) return;
try {
_correctedImagePath = await _distortionService.applyCorrection(
_imagePath!,
_distortionParams!,
);
_distortionCorrectionEnabled = true;
notifyListeners();
} catch (e) {
_errorMessage = 'Erreur lors de la correction: $e';
notifyListeners();
}
}
/// Active ou désactive l'affichage de l'image corrigée
void setDistortionCorrectionEnabled(bool enabled) {
if (enabled && _correctedImagePath == null && _distortionParams != null) {
// Si on active mais pas encore d'image corrigée, la créer
applyDistortionCorrection();
} else {
_distortionCorrectionEnabled = enabled;
notifyListeners();
}
}
/* version deux a tester*/
/// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel)
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
// 2. Vérification si une correction est réellement nécessaire
if (_distortionParams != null && _distortionParams!.needsCorrection) {
// 3. Application immédiate de la transformation (méthode asynchrone)
await applyDistortionCorrection();
} else {
notifyListeners(); // On prévient quand même si pas de correction
}
}
Future<void> runFullDistortionWorkflow() async {
_state = AnalysisState.loading; // Affiche un spinner sur votre UI
notifyListeners();
try {
calculateDistortion(); // Calcule les paramètres
await applyDistortionCorrection(); // Génère le fichier corrigé
_distortionCorrectionEnabled = true; // Active l'affichage
_state = AnalysisState.success;
} catch (e) {
_errorMessage = "Erreur de rendu : $e";
_state = AnalysisState.error;
} finally {
notifyListeners();
}
}
/* fin section deux a tester*/
int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) {
return _scoreCalculatorService.calculateConcentricScore(
@@ -688,16 +238,63 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
}
/// Exporte l'image et le json vers le backend IA.
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
Future<bool> exportToAiBackend({
String? sessionId,
int? distance,
String? weapon,
}) async {
if (_imagePath == null || _targetType == null) {
_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: sessionId ?? 'export',
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
shots: _shots,
distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown',
);
_state = AnalysisState.success;
if (!success) {
_errorMessage = "Échec de l'export vers le serveur IA.";
}
notifyListeners();
return success;
}
/// 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) {
throw Exception('Cannot save: missing image or target type');
}
final session = await _sessionRepository.createSession(
targetType: _targetType!,
final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
imagePath: _imagePath!,
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(),
sessionId: sessionId ?? 'standalone',
targetType: _targetType!,
shots: _shots,
totalScore: totalScore,
groupingDiameter: _groupingResult?.diameter,
groupingCenterX: _groupingResult?.centerX,
@@ -708,11 +305,41 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius,
);
// Update shots with session ID
_shots = session.shots;
notifyListeners();
if (sessionId != null && sessionId != 'standalone') {
final existingSession = await _sessionRepository.getSession(sessionId);
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
@@ -721,6 +348,7 @@ class AnalysisProvider extends ChangeNotifier {
_errorMessage = null;
_imagePath = null;
_targetType = null;
_cropRotation = 0.0;
_targetCenterX = 0.5;
_targetCenterY = 0.5;
_targetRadius = 0.4;
@@ -731,11 +359,25 @@ class AnalysisProvider extends ChangeNotifier {
_shots = [];
_scoreResult = null;
_groupingResult = null;
_referenceImpacts = [];
_learnedCharacteristics = null;
_distortionCorrectionEnabled = false;
_distortionParams = null;
_correctedImagePath = null;
notifyListeners();
}
/// Met à jour la position d'un impact de tir en direct (Glisser-Déposer).
void updateShotPosition(String shotId, double newX, double newY) {
final index = _shots.indexWhere((s) => s.id == shotId);
if (index != -1) {
// 1. On calcule dynamiquement le nouveau score de la zone survolée
final newScore = _calculateShotScore(newX, newY);
// 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
// 3. On applique les deux méthodes natives de recalcul de ton application
_recalculateScores();
_recalculateGrouping();
// 4. On demande à l'écran de se redessiner en direct
notifyListeners();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,209 @@
/// Écran d'édition des impacts — PLEIN ÉCRAN dédié au zoom et au placement.
///
/// Cet écran est volontairement minimal : un Scaffold dont le body est
/// directement un InteractiveViewer (sans SingleScrollView ni AspectRatio
/// contraint autour). C'est la configuration la plus fiable pour le pinch :
/// l'InteractiveViewer reçoit les deux doigts sans concurrence avec un
/// scroll vertical ou une transformation parente.
///
/// Interactions :
/// - Tap sur zone vide -> ajoute un impact
/// - Tap sur un impact -> ouvre l'édition (score / suppression)
/// - Appui long + glisser -> déplace l'impact
///
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
/// AnalysisProvider (passé en ChangeNotifierProvider.value côté appelant).
library;
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../data/models/shot.dart';
import 'analysis_provider.dart';
import 'widgets/target_overlay.dart';
import 'widgets/shot_details_sheet.dart';
class ImpactEditorScreen extends StatefulWidget {
const ImpactEditorScreen({super.key});
@override
State<ImpactEditorScreen> createState() => _ImpactEditorScreenState();
}
class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
final TransformationController _transformationController =
TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
String? _movingShotId;
@override
void initState() {
super.initState();
_transformationController.addListener(_onTransformChanged);
}
@override
void dispose() {
_transformationController.removeListener(_onTransformChanged);
_transformationController.dispose();
super.dispose();
}
void _onTransformChanged() {
final scale = _transformationController.value.getMaxScaleOnAxis();
if (scale != _currentZoomScale) {
setState(() => _currentZoomScale = scale);
}
}
/// Convertit une position globale en coordonnées relatives (0..1) sur l'image.
Offset? _toImageRelative(Offset globalPosition) {
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return null;
final local = box.globalToLocal(globalPosition);
final relX = (local.dx / box.size.width).clamp(0.0, 1.0);
final relY = (local.dy / box.size.height).clamp(0.0, 1.0);
return Offset(relX, relY);
}
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
{double tolerance = 0.04}) {
Shot? closest;
double minDistance = double.infinity;
for (final shot in provider.shots) {
final dx = shot.x - rel.dx;
final dy = shot.y - rel.dy;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < minDistance && distance < tolerance) {
minDistance = distance;
closest = shot;
}
}
return closest;
}
@override
Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>();
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: Text('Placement des impacts (${provider.shotCount})'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Retour à la calibration',
onPressed: () => Navigator.pop(context, false),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'VALIDER',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
body: Column(
children: [
// Bandeau d'aide compact
Container(
width: double.infinity,
color: Colors.white10,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text(
'Tap : ajouter • Tap sur impact : éditer • Appui long : déplacer • Pincer : zoomer',
style: TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
),
),
// Zone image plein écran : InteractiveViewer dans un body nu.
Expanded(
child: InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 12.0,
boundaryMargin: const EdgeInsets.all(80),
panEnabled: _movingShotId == null,
child: Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
// TAP : éditer si on touche un impact, sinon ajouter.
onTapUp: (details) {
if (_movingShotId != null) return;
final rel = _toImageRelative(details.globalPosition);
if (rel == null) return;
final hit = _hitTestShot(provider, rel);
if (hit != null) {
showShotDetailsSheet(context, provider, hit);
} else {
provider.addShot(rel.dx, rel.dy);
}
},
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
onLongPressStart: (details) {
final rel = _toImageRelative(details.globalPosition);
if (rel == null) return;
final hit = _hitTestShot(provider, rel, tolerance: 0.06);
if (hit != null) {
setState(() => _movingShotId = hit.id);
}
},
onLongPressMoveUpdate: (details) {
if (_movingShotId == null) return;
// Décalage pour que l'impact reste visible au-dessus du doigt.
final adjusted =
details.globalPosition + const Offset(-25, -35);
final rel = _toImageRelative(adjusted);
if (rel == null) return;
provider.updateShotPosition(
_movingShotId!, rel.dx, rel.dy);
},
onLongPressEnd: (_) {
if (_movingShotId != null) {
setState(() => _movingShotId = null);
}
},
child: Stack(
children: [
Image.file(
File(provider.imagePath!),
key: _imageKey,
fit: BoxFit.contain,
),
Positioned.fill(
child: TargetOverlay(
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
// L'ajout et la sélection sont gérés par le
// GestureDetector parent ci-dessus.
onShotTapped: (shot) =>
showShotDetailsSheet(context, provider, shot),
),
),
],
),
),
),
),
),
],
),
);
}
}

View File

@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../services/grouping_analyzer_service.dart';
class GroupingStats extends StatelessWidget {
@@ -43,31 +44,64 @@ class GroupingStats extends StatelessWidget {
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(),
_buildQualityBadge(context),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStat(
context,
'Diametre',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
Expanded(
child: _buildStat(
context,
'Étalement',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
),
_buildStat(
context,
'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
Expanded(
child: _buildStat(
context,
'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
),
_buildStat(
context,
'Decalage',
offsetDescription,
icon: Icons.compare_arrows,
Expanded(
child: _buildStat(
context,
'Décalage',
offsetDescription,
icon: Icons.compare_arrows,
),
),
],
),
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
const SizedBox(height: 4),
Text(
value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre';
return 'Centré';
}
String vertical = '';
String horizontal = '';
if (offsetY < -0.02) {
vertical = 'H';
vertical = 'Haut';
} else if (offsetY > 0.02) {
vertical = 'B';
vertical = 'Bas';
}
if (offsetX < -0.02) {
horizontal = 'G';
horizontal = 'Gauche';
} else if (offsetX > 0.02) {
horizontal = 'D';
horizontal = 'Droite';
}
if (vertical.isNotEmpty && horizontal.isNotEmpty) {

View File

@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../data/models/target_type.dart';
import '../../../services/score_calculator_service.dart';
@@ -44,6 +45,31 @@ class ScoreCard extends StatelessWidget {
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(),
@@ -67,11 +93,12 @@ class ScoreCard extends StatelessWidget {
shotCount > 0
? (totalScore / shotCount).toStringAsFixed(1)
: '-',
subtitle: '/ $maxScore',
),
if (scoreResult != null)
_buildScoreStat(
context,
'Pourcentage',
'Réussite',
'${scoreResult!.percentage.toStringAsFixed(0)}%',
),
],

View File

@@ -0,0 +1,85 @@
/// Bottom sheet de détails d'un impact : modification du score et suppression.
///
/// Partagée entre l'écran d'analyse (consultation du plotting) et l'éditeur
/// d'impacts plein écran, qui opèrent sur le même AnalysisProvider.
library;
import 'package:flutter/material.dart';
import '../../../data/models/shot.dart';
import '../analysis_provider.dart';
void showShotDetailsSheet(
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Impact #${provider.shots.indexOf(shot) + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
'ID: ${shot.id}',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey, fontSize: 10),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.score),
title: const Text('Modifier le score'),
trailing: DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (index) => index)
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(
'$s',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
)
.toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shot.id, newScore);
Navigator.pop(context);
}
},
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
provider.removeShot(shot.id);
Navigator.pop(context);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text(
'SUPPRIMER',
style: TextStyle(color: Colors.red),
),
),
),
],
),
],
),
),
);
}

View File

@@ -4,7 +4,7 @@
/// Les anneaux sont répartis proportionnellement.
library;
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart';
@@ -18,13 +18,13 @@ class TargetCalibration extends StatefulWidget {
final TargetType targetType;
final List<double>? initialRingRadii;
final Function(
double centerX,
double centerY,
double innerRadius,
double radius,
int ringCount, {
List<double>? ringRadii,
})
double centerX,
double centerY,
double innerRadius,
double radius,
int ringCount, {
List<double>? ringRadii,
})
onCalibrationChanged;
const TargetCalibration({
@@ -40,10 +40,10 @@ class TargetCalibration extends StatefulWidget {
});
@override
State<TargetCalibration> createState() => _TargetCalibrationState();
State<TargetCalibration> createState() => TargetCalibrationState();
}
class _TargetCalibrationState extends State<TargetCalibration> {
class TargetCalibrationState extends State<TargetCalibration> {
late double _centerX;
late double _centerY;
late double _radius;
@@ -51,10 +51,19 @@ class _TargetCalibrationState extends State<TargetCalibration> {
late int _ringCount;
late List<double> _ringRadii;
// Variable dédiée pour piloter la jauge orange sans conflit
late double _currentEspacementRatio;
bool _isDraggingCenter = false;
bool _isDraggingRadius = false;
bool _isDraggingInnerRadius = false;
bool _showEspacement = false;
double _baseRadiusBeforeScale = 1.0;
// SAUVEGARDE CRITIQUE : Pour mémoriser la structure d'origine de l'IA
List<double>? _originalRingRadii;
@override
void initState() {
super.initState();
@@ -63,19 +72,37 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount;
// On mémorise la configuration d'usine de l'image
if (widget.initialRingRadii != null) {
_originalRingRadii = List.from(widget.initialRingRadii!);
}
// Initialisation du ratio par défaut
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
_initRingRadii();
}
void _initRingRadii() {
if (widget.initialRingRadii != null &&
widget.initialRingRadii!.length == _ringCount) {
/// Fige et propage la calibration courante vers le provider.
///
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
/// correspond exactement au dernier réglage validé, sans dépendre d'un
/// éventuel rebuild intermédiaire.
void commitCalibration() {
_notifyChange();
}
void _initRingRadii({bool forceRecalculate = false}) {
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
_ringRadii = List.from(_originalRingRadii!);
} else if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!);
} else {
// Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius;
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
});
}
}
@@ -85,12 +112,10 @@ class _TargetCalibrationState extends State<TargetCalibration> {
super.didUpdateWidget(oldWidget);
bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX &&
!_isDraggingCenter) {
if (widget.initialCenterX != oldWidget.initialCenterX && !_isDraggingCenter) {
_centerX = widget.initialCenterX;
}
if (widget.initialCenterY != oldWidget.initialCenterY &&
!_isDraggingCenter) {
if (widget.initialCenterY != oldWidget.initialCenterY && !_isDraggingCenter) {
_centerY = widget.initialCenterY;
}
if (widget.initialRingCount != oldWidget.initialRingCount) {
@@ -101,12 +126,20 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_radius = widget.initialRadius;
shouldReinit = true;
}
if (widget.initialInnerRadius != oldWidget.initialInnerRadius &&
!_isDraggingInnerRadius) {
if (widget.initialInnerRadius != oldWidget.initialInnerRadius && !_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius;
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii) {
// On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
// réellement d'une nouvelle détection (ils diffèrent de notre état courant).
// Sinon il s'agit de l'écho de notre propre _notifyChange (aller-retour via
// le provider) : le clobber effacerait le profil d'origine et casserait le
// bouton de réinitialisation de l'espacement.
if (widget.initialRingRadii != oldWidget.initialRingRadii &&
widget.initialRingRadii != null &&
!listEquals(widget.initialRingRadii, _ringRadii)) {
_originalRingRadii = List.from(widget.initialRingRadii!);
shouldReinit = true;
}
@@ -121,107 +154,246 @@ class _TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) {
final size = constraints.biggest;
return GestureDetector(
onPanStart: (details) => _onPanStart(details, size),
onPanUpdate: (details) => _onPanUpdate(details, size),
onPanEnd: (_) => _onPanEnd(),
child: CustomPaint(
size: size,
painter: _CalibrationPainter(
centerX: _centerX,
centerY: _centerY,
radius: _radius,
innerRadius: _innerRadius,
ringCount: _ringCount,
ringRadii: _ringRadii,
targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius,
isDraggingInnerRadius: _isDraggingInnerRadius,
return Stack(
children: [
GestureDetector(
onScaleStart: (details) {
_baseRadiusBeforeScale = _radius;
final tapX = details.localFocalPoint.dx / size.width;
final tapY = details.localFocalPoint.dy / size.height;
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
setState(() {
_isDraggingCenter = true;
});
}
},
onScaleUpdate: (details) => _onScaleUpdate(details, size),
onScaleEnd: (_) => _onScaleEnd(),
child: CustomPaint(
size: size,
painter: _CalibrationPainter(
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) {
final tapX = details.localPosition.dx / size.width;
final tapY = details.localPosition.dy / size.height;
// Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
// Check if tapping on outer radius handle
final minDim = math.min(size.width, size.height);
final outerRadius = _radius;
final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY;
final distToOuterHandle = _distance(
tapX,
tapY,
radiusHandleX.clamp(0.0, 1.0),
radiusHandleY.clamp(0.0, 1.0),
Widget buildDirectionalControls(BuildContext context, Size size) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildSignLabel(''),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
const SizedBox(width: 40),
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
],
),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
],
),
const SizedBox(width: 12),
_buildSignLabel('+'),
],
),
);
// Check if tapping on inner radius handle (top edge of innermost circle)
final actualInnerRadius = _innerRadius;
final innerHandleX = _centerX;
final innerHandleY = _centerY - actualInnerRadius * minDim / size.height;
final distToInnerHandle = _distance(
tapX,
tapY,
innerHandleX.clamp(0.0, 1.0),
innerHandleY.clamp(0.0, 1.0),
);
// Increase touch target size slightly for handles
if (distToCenter < 0.05) {
setState(() {
_isDraggingCenter = true;
});
} else if (distToOuterHandle < 0.05) {
setState(() {
_isDraggingRadius = true;
});
} else if (distToInnerHandle < 0.05) {
setState(() {
_isDraggingInnerRadius = true;
});
} else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center
setState(() {
_isDraggingCenter = true;
});
}
}
void _onPanUpdate(DragUpdateDetails details, Size size) {
final deltaX = details.delta.dx / size.width;
final deltaY = details.delta.dy / size.height;
final minDim = math.min(size.width, size.height);
// Symbole purement décoratif affiché de part et d'autre de la croix.
Widget _buildSignLabel(String text) {
return Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
);
}
setState(() {
if (_isDraggingCenter) {
// Move center
_centerX = _centerX + deltaX;
_centerY = _centerY + deltaY;
} else if (_isDraggingRadius) {
// Adjust outer radius
final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0);
_initRingRadii(); // Recalculate linear separation
} else if (_isDraggingInnerRadius) {
// Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY)
final newInnerRadius = _innerRadius - deltaY * (size.height / minDim);
_innerRadius = newInnerRadius.clamp(
0.01,
math.max(0.01, _radius - 0.01),
);
_initRingRadii(); // Recalculate linear separation
}
});
Widget _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,
@@ -232,7 +404,31 @@ class _TargetCalibrationState extends State<TargetCalibration> {
);
}
void _onPanEnd() {
void _moveCenterByPixels(double dx, double dy, Size size) {
setState(() {
_centerX = _centerX + (dx / size.width);
_centerY = _centerY + (dy / size.height);
});
_notifyChange();
}
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
setState(() {
if (details.pointerCount == 2) {
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95);
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: _showEspacement);
} else if (_isDraggingCenter) {
final deltaX = details.focalPointDelta.dx / size.width;
final deltaY = details.focalPointDelta.dy / size.height;
_centerX = _centerX + deltaX;
_centerY = _centerY + deltaY;
}
});
_notifyChange();
}
void _onScaleEnd() {
setState(() {
_isDraggingCenter = false;
_isDraggingRadius = false;
@@ -277,7 +473,6 @@ class _CalibrationPainter extends CustomPainter {
final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim;
final innerRadiusPx = innerRadius * minDim;
if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
@@ -285,43 +480,17 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
}
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) {
final crosshairLinePaint = Paint()
..color = AppTheme.successColor.withValues(alpha: 0.5)
..strokeWidth = 1;
canvas.drawLine(
Offset(0, centerPx.dy),
Offset(size.width, centerPx.dy),
crosshairLinePaint,
);
canvas.drawLine(
Offset(centerPx.dx, 0),
Offset(centerPx.dx, size.height),
crosshairLinePaint,
);
final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
}
// Draw center handle
_drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw inner radius handle
_drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx);
// Draw instructions
_drawInstructions(canvas, size);
}
void _drawConcentricZones(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) {
final ratio = i / ringCount;
@@ -342,13 +511,10 @@ class _CalibrationPainter extends CustomPainter {
final strokePaint = Paint()
..style = PaintingStyle.stroke
..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--) {
final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i];
@@ -356,22 +522,14 @@ class _CalibrationPainter extends CustomPainter {
canvas.drawCircle(center, zoneRadius, strokePaint);
}
// Draw zone labels (only if within visible area)
final textPainter = TextPainter(textDirection: TextDirection.ltr);
for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0;
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final textSpanRatio = i > 0 ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) : 0.0;
final zoneRadius = baseRadius * (ringRadius + textSpanRatio) / 2;
// Score: center = 10, decrement by 1 for each ring
final score = 10 - i;
// Only draw label if it's within the visible area
final labelX = center.dx + zoneRadius;
if (labelX < 0 || labelX > size.width) continue;
@@ -386,251 +544,49 @@ class _CalibrationPainter extends CustomPainter {
);
textPainter.layout();
// Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(
canvas,
Offset(labelX - textPainter.width / 2, labelY),
);
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
}
}
}
void _drawSilhouetteZones(
Canvas canvas,
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now)
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
final silhouetteWidth = radius * 0.8;
final silhouetteHeight = radius * 2;
paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect(
Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint,
);
canvas.drawRect(Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), paint);
}
void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle
final outerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 3;
final outerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.stroke..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint);
// Inner dot
final innerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.fill;
final innerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint);
// Crosshair
final crossPaint = Paint()
..color = isDraggingCenter ? 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 + 8, center.dy),
Offset(center.dx + 20, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy - 20),
Offset(center.dx, center.dy - 8),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy + 8),
Offset(center.dx, center.dy + 20),
crossPaint,
);
}
void _drawRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Radius handle on the right edge of the outermost ring
final actualHandleX = center.dx + baseRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
// Check if handle is clamped (radius extends beyond visible area)
final isClamped = actualHandleX > size.width - 20;
final paint = Paint()
..color = isDraggingRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : AppTheme.warningColor)
..style = PaintingStyle.fill;
// Draw handle as a small circle with arrows
canvas.drawCircle(handlePos, 14, paint);
// Draw arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Left arrow
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy + 4),
arrowPaint,
);
// Right arrow
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy + 4),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'EXT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16),
);
}
void _drawInnerRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double innerRadiusPx,
) {
// Inner radius handle on the top edge of the innermost ring
final actualHandleY = center.dy - innerRadiusPx;
final clampedHandleX = center.dx.clamp(20.0, size.width - 20);
final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
final isClamped = actualHandleY < 20.0;
final paint = Paint()
..color = isDraggingInnerRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : Colors.purpleAccent)
..style = PaintingStyle.fill;
// Draw handle
canvas.drawCircle(handlePos, 14, paint);
// Up/Down arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Up arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx - 4, handlePos.dy - 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx + 4, handlePos.dy - 8),
arrowPaint,
);
// Down arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx - 4, handlePos.dy + 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx + 4, handlePos.dy + 8),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'INT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy - 24),
);
final crossPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy + 4), Offset(center.dx, center.dy + 20), crossPaint);
}
void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon';
final textPainter = TextPainter(
text: TextSpan(
text: instruction,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
backgroundColor: Colors.black54,
),
),
text: TextSpan(text: instruction, style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 12, backgroundColor: Colors.black54)),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset((size.width - textPainter.width) / 2, size.height - 30),
);
textPainter.paint(canvas, Offset((size.width - textPainter.width) / 2, size.height - 30));
}
@override
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) {
bool shouldRepaint(CustomPainter oldDelegate) {
if (oldDelegate is! _CalibrationPainter) return true;
return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY ||
radius != oldDelegate.radius ||
@@ -641,4 +597,4 @@ class _CalibrationPainter extends CustomPainter {
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
ringRadii != oldDelegate.ringRadii;
}
}
}

View File

@@ -1,8 +1,9 @@
/// Overlay visuel de la cible.
///
/// 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
/// d'impacts et la sélection d'impacts existants.
/// Dessine les anneaux de la cible, les impacts et le cercle de groupement.
/// Gère uniquement 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;
import 'package:flutter/material.dart';
@@ -19,11 +20,9 @@ class TargetOverlay extends StatelessWidget {
final int ringCount;
final List<double>? ringRadii;
final void Function(Shot shot)? onShotTapped;
final void Function(double x, double y)? onAddShot;
final double? groupingCenterX;
final double? groupingCenterY;
final double? groupingDiameter;
final List<Shot>? referenceImpacts;
final double zoomScale;
final bool showRings;
@@ -37,87 +36,77 @@ class TargetOverlay extends StatelessWidget {
this.ringCount = 10,
this.ringRadii,
this.onShotTapped,
this.onAddShot,
this.groupingCenterX,
this.groupingCenterY,
this.groupingDiameter,
this.referenceImpacts,
this.zoomScale = 1.0,
this.showRings = false,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapUp: (details) {
if (onAddShot != null) {
// Utiliser les constraints pour un calcul précis
final relX = details.localPosition.dx / constraints.maxWidth;
final relY = details.localPosition.dy / constraints.maxHeight;
onAddShot!(relX, relY);
}
},
child: CustomPaint(
painter: _TargetOverlayPainter(
shots: shots,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetRadius: targetRadius,
targetType: targetType,
ringCount: ringCount,
ringRadii: ringRadii,
groupingCenterX: groupingCenterX,
groupingCenterY: groupingCenterY,
groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts,
zoomScale: zoomScale,
showRings: showRings,
),
child: Stack(
// IMPORTANT : plus de GestureDetector global ici.
// L'ancien GestureDetector (onTapUp couvrant toute la surface, en
// HitTestBehavior.translucent) volait les pointeurs au pinch de
// l'InteractiveViewer parent et rendait le zoom capricieux.
//
// Désormais :
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
// zones de tap localisées (deferToChild) placées sur chaque impact.
return IgnorePointer(
ignoring: false,
child: CustomPaint(
painter: _TargetOverlayPainter(
shots: shots,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetRadius: targetRadius,
targetType: targetType,
ringCount: ringCount,
ringRadii: ringRadii,
groupingCenterX: groupingCenterX,
groupingCenterY: groupingCenterY,
groupingDiameter: groupingDiameter,
zoomScale: zoomScale,
showRings: showRings,
),
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
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(
left: 0,
top: 0,
right: 0,
bottom: 0,
child: LayoutBuilder(
builder: (context, innerConstraints) {
final x = shot.x * innerConstraints.maxWidth;
final y = shot.y * innerConstraints.maxHeight;
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran)
final tapSize = 30 / zoomScale;
final halfTapSize = tapSize / 2;
return Stack(
children: [
Positioned(
left: x - halfTapSize,
top: y - halfTapSize,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => onShotTapped?.call(shot),
child: Container(
width: tapSize,
height: tapSize,
decoration: const BoxDecoration(
color: Colors.transparent,
shape: BoxShape.circle,
),
),
),
),
],
);
},
left: x - halfTapSize,
top: y - halfTapSize,
child: GestureDetector(
// deferToChild : ne capte le toucher QUE sur la zone du
// Container (un cercle opaque au hit-test), pas ailleurs.
// Le reste de la surface reste donc disponible pour le
// pinch/pan de l'InteractiveViewer.
behavior: HitTestBehavior.deferToChild,
onTap: () => onShotTapped?.call(shot),
child: Container(
width: tapSize,
height: tapSize,
decoration: const BoxDecoration(
// Opaque pour le hit-test (couleur transparente visuellement
// mais non nulle), pour que le tap soit bien capté ici.
color: Color(0x01000000),
shape: BoxShape.circle,
),
),
),
);
}).toList(),
),
),
);
},
);
},
),
),
);
}
}
@@ -133,7 +122,6 @@ class _TargetOverlayPainter extends CustomPainter {
final double? groupingCenterX;
final double? groupingCenterY;
final double? groupingDiameter;
final List<Shot>? referenceImpacts;
final double zoomScale;
final bool showRings;
@@ -148,7 +136,6 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingCenterX,
this.groupingCenterY,
this.groupingDiameter,
this.referenceImpacts,
this.zoomScale = 1.0,
this.showRings = false,
});
@@ -169,13 +156,6 @@ class _TargetOverlayPainter extends CustomPainter {
for (final shot in shots) {
_drawImpact(canvas, size, shot);
}
// Draw reference impacts (with different color)
if (referenceImpacts != null) {
for (final ref in referenceImpacts!) {
_drawReferenceImpact(canvas, size, ref);
}
}
}
void _drawTargetCenter(Canvas canvas, Size size) {
@@ -211,8 +191,8 @@ class _TargetOverlayPainter extends CustomPainter {
final prevMultiplier = i == 0
? 0.0
: (ringRadii != null && ringRadii!.length == ringCount)
? ringRadii![i - 1]
: i / ringCount;
? ringRadii![i - 1]
: i / ringCount;
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
final score = 10 - i;
@@ -292,16 +272,17 @@ class _TargetOverlayPainter extends CustomPainter {
final strokeWidth = 3 / 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()
..color = AppTheme.impactOutlineColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
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()
..color = AppTheme.impactColor
..color = AppTheme.impactColor.withValues(alpha: 0.3)
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
@@ -324,48 +305,6 @@ class _TargetOverlayPainter extends CustomPainter {
);
}
void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) {
final x = ref.x * size.width;
final y = ref.y * size.height;
// Tailles fixes divisées par le zoom pour rester constantes à l'écran
final outerRadius = 12 / zoomScale;
final innerRadius = 10 / zoomScale;
final strokeWidth = 3 / zoomScale;
final fontSize = 12 / zoomScale;
// Draw outer circle (white outline for visibility)
final outlinePaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
// Draw reference marker (purple)
final refPaint = Paint()
..color = Colors.deepPurple
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), innerRadius, refPaint);
// Draw "R" to indicate reference
final textPainter = TextPainter(
text: TextSpan(
text: 'R',
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
);
}
@override
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
return shots != oldDelegate.shots ||
@@ -377,8 +316,7 @@ class _TargetOverlayPainter extends CustomPainter {
groupingCenterX != oldDelegate.groupingCenterX ||
groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts ||
zoomScale != oldDelegate.zoomScale ||
showRings != oldDelegate.showRings;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,25 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
// Importations ajustées selon ton arborescence à gauche
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../../services/image_crop_service.dart';
import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget {
final String imagePath;
final TargetType targetType;
final double? initialScale;
final Offset? initialOffset;
const CropScreen({
super.key,
required this.imagePath,
required this.targetType,
this.initialScale,
this.initialOffset,
});
@override
@@ -20,6 +27,44 @@ class CropScreen extends StatefulWidget {
}
class _CropScreenState extends State<CropScreen> {
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 _imageLoaded = false;
Size? _imageSize;
late Size _viewportSize;
late double _cropSize;
@override
void initState() {
super.initState();
_loadImageInfo();
}
Future<void> _loadImageInfo() async {
final file = File(widget.imagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) {
setState(() {
_imageSize = Size(
decodedImage.width.toDouble(),
decodedImage.height.toDouble(),
);
_imageLoaded = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -29,55 +74,148 @@ class _CropScreenState extends State<CropScreen> {
elevation: 0,
centerTitle: true,
title: const Text(
'Validation image',
'Centrage de la cible',
style: TextStyle(color: Colors.white, fontSize: 18),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
actions: [
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Réinitialiser l\'orientation',
onPressed: () {
setState(() {
_rotation = 0.0;
_scale = 1.0;
_offset = Offset.zero;
_initializeImagePosition();
});
},
),
],
),
body: Column(
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
children: [
// L'image capturée
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(
'Alignez et pivotez la cible sur la croix',
style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 4),
Text(
'Glissez à un doigt pour déplacer, pincez pour zoomer',
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
),
],
),
),
// Zone interactive de crop, CARRÉE. La photo la remplit entièrement
// (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre
// est récupérable en déplaçant/zoomant. La sortie d'analyse reste
// carrée, donc la cible n'est pas déformée.
Expanded(
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(widget.imagePath),
fit: BoxFit.contain,
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()),
),
),
),
),
),
),
// Bouton Modifier
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(vertical: 20),
child: ElevatedButton.icon(
onPressed: () {
// Logique de modification à venir
},
icon: const Icon(Icons.crop_rotate),
label: const Text('Modifier'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
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, 0, 20, 30),
padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
child: Row(
children: [
Expanded(
@@ -88,30 +226,20 @@ class _CropScreenState extends State<CropScreen> {
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Recommencer', style: TextStyle(color: Colors.white)),
child: const Text('Retour', style: TextStyle(color: Colors.white)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: widget.imagePath,
targetType: widget.targetType,
),
),
);
},
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('Valider', style: TextStyle(fontWeight: FontWeight.bold)),
child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
@@ -121,4 +249,245 @@ class _CropScreenState extends State<CropScreen> {
),
);
}
Widget _buildInteractiveCrop() {
return LayoutBuilder(
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
// visée = toute la zone visible → aucun bord noir autour du cadre.
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
_initializeImagePosition();
}
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: Stack(
children: [
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
Positioned.fill(
child: Center(
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scaleByDouble(_scale, _scale, _scale, 1.0)
..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
fit: BoxFit.cover,
width: _viewportSize.width,
height: _viewportSize.height,
),
),
),
),
// 2. Les lignes vertes de visée prolongées
Positioned.fill(
child: IgnorePointer(
child: Stack(
children: [
Center(
child: Container(
width: double.infinity,
height: 1.5,
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
],
),
),
),
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
Positioned.fill(
child: IgnorePointer(
child: ColorFiltered(
colorFilter: ColorFilter.mode(
const Color(0xFF101214),
BlendMode.srcOut,
),
child: Stack(
children: [
Center(
child: Container(
width: _cropSize,
height: _cropSize,
color: Colors.black,
),
),
],
),
),
),
),
// 4. Ton overlay avec les coins blancs
Positioned.fill(
child: IgnorePointer(
child: CropOverlay(cropSize: _cropSize, showGrid: false),
),
),
],
),
);
},
);
}
void _initializeImagePosition() {
if (_imageSize == null) return;
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
_scale = widget.initialScale ?? 1.0;
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
if (widget.initialOffset != null) {
_offset = widget.initialOffset!;
} else {
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
}
}
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
// Les symboles « » et « + » de part et d'autre sont purement décoratifs.
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('+'),
],
);
}
Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(6)),
margin: const EdgeInsets.all(2),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 22),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
);
}
Widget _buildCropSignLabel(String text) {
return Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 24, fontWeight: FontWeight.bold),
);
}
void _nudge(double dx, double dy) {
setState(() {
_offset = _offset + Offset(dx, dy);
});
}
void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
_startFocalPoint = details.focalPoint;
_startOffset = _offset;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
final delta = details.focalPoint - _startFocalPoint;
_offset = _startOffset + delta;
});
}
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
// 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.
final double displayPerSourcePx = math.max(
_viewportSize.width / _imageSize!.width,
_viewportSize.height / _imageSize!.height,
);
// 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;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
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,
initialCenterX: targetCenterX,
initialCenterY: targetCenterY,
cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
),
),
);
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
);
}
}
}
}

View File

@@ -68,7 +68,7 @@ class _CropOverlayPainter extends CustomPainter {
_drawGrid(canvas, cropRect);
}
// Dessiner le point central (croix)
// Dessiner le point central (croix interne vert fluo)
_drawCenterPoint(canvas, cropRect);
}
@@ -165,39 +165,44 @@ class _CropOverlayPainter extends CustomPainter {
}
void _drawCenterPoint(Canvas canvas, Rect rect) {
// Peinture de la croix interne (Vert Fluo)
final centerPaint = Paint()
..color = Colors.white.withValues(alpha: 0.8)
..color = const Color(0xFF00FF00)
..style = PaintingStyle.stroke
..strokeWidth = 2;
..strokeWidth = 2.0;
const size = 10.0;
// Cercle extérieur (Vert Fluo fin)
final circlePaint = Paint()
..color = const Color(0xFF00FF00).withValues(alpha: 0.9)
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
const circleRadius = 10.0; // Augmenté légèrement à 10 pour que la croix respire
final centerX = rect.center.dx;
final centerY = rect.center.dy;
// Ligne horizontale
// 1. Dessiner le cercle de contour
canvas.drawCircle(rect.center, circleRadius, circlePaint);
// 2. Dessiner la croix UNIQUEMENT à l'intérieur du cercle
// Ligne horizontale (de gauche à droite, reste à l'intérieur)
canvas.drawLine(
Offset(centerX - size, centerY),
Offset(centerX + size, centerY),
Offset(centerX - circleRadius, centerY),
Offset(centerX + circleRadius, centerY),
centerPaint,
);
// Ligne verticale
// Ligne verticale (de haut en bas, reste à l'intérieur)
canvas.drawLine(
Offset(centerX, centerY - size),
Offset(centerX, centerY + size),
Offset(centerX, centerY - circleRadius),
Offset(centerX, centerY + circleRadius),
centerPaint,
);
// Petit cercle central pour précision (optionnel, mais aide à viser)
canvas.drawCircle(
rect.center,
2,
Paint()..color = Colors.red.withValues(alpha: 0.6),
);
}
@override
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;
}
}
}

View File

@@ -0,0 +1,595 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/models/maintenance.dart';
import '../../data/repositories/session_repository.dart';
class WeaponDetailScreen extends StatefulWidget {
final Weapon weapon;
const WeaponDetailScreen({super.key, required this.weapon});
@override
State<WeaponDetailScreen> createState() => _WeaponDetailScreenState();
}
class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon;
List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0;
int _sessionCount = 0;
bool _isLoading = true;
@override
void initState() {
super.initState();
_weapon = widget.weapon;
_loadData();
}
Future<void> _loadData() async {
final repository = context.read<SessionRepository>();
final history = await repository.getMaintenanceHistory(_weapon.id);
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
if (mounted) {
setState(() {
_maintenance = history;
_totalRounds = rounds;
_sessionCount = sessions;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_weapon.customName ?? _weapon.name),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: _showEditWeaponDialog,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatsSection(),
const SizedBox(height: 24),
_buildInfoCard(),
const SizedBox(height: 24),
_buildOptionsCard(),
const SizedBox(height: 24),
_buildHistorySection(),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _showAddMaintenanceDialog,
label: const Text('Entretien'),
icon: const Icon(Icons.build),
),
);
}
Widget _buildStatsSection() {
return Row(
children: [
Expanded(
child: _buildStatCard(
'Total Tirs',
_totalRounds.toString(),
Icons.ads_click,
AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
'Sessions',
_sessionCount.toString(),
Icons.history,
Colors.orange,
),
),
],
);
}
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 8),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildInfoRow('Modèle', _weapon.name),
_buildInfoRow('Type', _weapon.type.displayName),
_buildInfoRow('Calibre', _weapon.caliber),
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
const Text('Notes:', style: TextStyle(fontWeight: FontWeight.w500)),
Text(_weapon.notes!, style: const TextStyle(color: Colors.grey)),
],
],
),
),
);
}
Widget _buildOptionsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
IconButton(
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
),
onPressed: _showEditAccessoriesDialog,
),
],
),
const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
_buildInfoRow('Détente', _weapon.trigger ?? 'Standard'),
if (_weapon.customName != null)
_buildInfoRow('Surnom', _weapon.customName!),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(color: Colors.grey)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
}
Widget _buildHistorySection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Historique d\'entretien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
const SizedBox(height: 8),
if (_maintenance.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Center(child: Text('Aucun entretien enregistré', style: TextStyle(color: Colors.grey))),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _maintenance.length,
itemBuilder: (context, index) {
final entry = _maintenance[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getMaintenanceColor(entry.type).withValues(alpha: 0.1),
child: Icon(_getMaintenanceIcon(entry.type), color: _getMaintenanceColor(entry.type), size: 20),
),
title: Text(entry.type.displayName),
subtitle: Text(entry.description),
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
onLongPress: () => _confirmDeleteMaintenance(entry),
),
);
},
),
],
);
}
Color _getMaintenanceColor(MaintenanceType type) {
switch (type) {
case MaintenanceType.cleaning: return Colors.blue;
case MaintenanceType.repair: return Colors.red;
case MaintenanceType.inspection: return Colors.green;
case MaintenanceType.upgrade: return Colors.purple;
case MaintenanceType.other: return Colors.grey;
}
}
IconData _getMaintenanceIcon(MaintenanceType type) {
switch (type) {
case MaintenanceType.cleaning: return Icons.opacity;
case MaintenanceType.repair: return Icons.build;
case MaintenanceType.inspection: return Icons.visibility;
case MaintenanceType.upgrade: return Icons.trending_up;
case MaintenanceType.other: return Icons.more_horiz;
}
}
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
// (ex: Modérateur) se retrouve caché sous le clavier.
Widget _autoScrollOnFocus(Widget child) {
return Builder(
builder: (context) => Focus(
canRequestFocus: false,
skipTraversal: true,
onFocusChange: (hasFocus) {
if (!hasFocus) return;
// On attend que le clavier ait fini de redimensionner la vue.
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
});
},
child: child,
),
);
}
void _showEditWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber);
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
final magCapController = TextEditingController(text: _weapon.magazineCapacity.toString());
final notesController = TextEditingController(text: _weapon.notes);
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
WeaponType selectedType = _weapon.type;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Modifier l\'arme'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_autoScrollOnFocus(TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
)),
DropdownButtonFormField<WeaponType>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
_autoScrollOnFocus(TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'),
)),
const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
_autoScrollOnFocus(Row(
children: [
Expanded(
child: TextField(
controller: magCountController,
decoration: const InputDecoration(labelText: 'Chargeurs'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: magCapController,
decoration: const InputDecoration(labelText: 'Capacité'),
keyboardType: TextInputType.number,
),
),
],
)),
_autoScrollOnFocus(TextField(
controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2,
)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Enregistrer')),
],
),
),
);
if (result == true) {
final updatedWeapon = _weapon.copyWith(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
notes: notesController.text,
optic: opticController.text.isEmpty ? null : opticController.text,
silencer: silencerController.text.isEmpty ? null : silencerController.text,
trigger: triggerController.text.isEmpty ? null : triggerController.text,
customName: customNameController.text.isEmpty ? null : customNameController.text,
);
await repository.updateWeapon(updatedWeapon);
if (!mounted) return;
setState(() {
_weapon = updatedWeapon;
});
}
}
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
// pouvoir comparer les scores selon la configuration utilisée.
void _showEditAccessoriesDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier les accessoires'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
'pour pouvoir comparer les scores selon la configuration.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(
labelText: 'Surnom de la configuration',
hintText: 'ex: Echelon + Holosun',
),
)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
],
),
);
if (result != true || !mounted) return;
final newOptic = opticController.text.isEmpty ? null : opticController.text;
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
// Rien à faire si aucun accessoire n'a changé.
final unchanged = newOptic == _weapon.optic &&
newSilencer == _weapon.silencer &&
newTrigger == _weapon.trigger;
if (unchanged) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
);
return;
}
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
if (newCustomName == null) {
final accessories = [newOptic, newSilencer, newTrigger]
.where((a) => a != null && a.isNotEmpty)
.join(' / ');
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
}
final newWeapon = await repository.addWeapon(
name: _weapon.name,
type: _weapon.type,
caliber: _weapon.caliber,
magazineCount: _weapon.magazineCount,
magazineCapacity: _weapon.magazineCapacity,
notes: _weapon.notes,
optic: newOptic,
silencer: newSilencer,
trigger: newTrigger,
customName: newCustomName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
);
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
);
}
void _showAddMaintenanceDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning;
DateTime selectedDate = DateTime.now();
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter un entretien'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<MaintenanceType>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: descController,
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
maxLines: 2,
),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() => selectedDate = picked);
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date',
suffixIcon: Icon(Icons.calendar_today, size: 18),
),
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
],
),
),
);
if (result == true && descController.text.isNotEmpty) {
await repository.addMaintenanceEntry(
weaponId: _weapon.id,
type: selectedType,
description: descController.text,
roundsSinceLast: _totalRounds,
date: selectedDate,
);
if (!mounted) return;
_loadData();
}
}
void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Supprimer cette entrée de l\'historique ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true) {
await repository.deleteMaintenanceEntry(entry.id);
if (!mounted) return;
_loadData();
}
}
}

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget {
const WeaponListScreen({super.key});
@override
State<WeaponListScreen> createState() => _WeaponListScreenState();
}
class _WeaponListScreenState extends State<WeaponListScreen> {
List<Weapon> _weapons = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadWeapons();
}
Future<void> _loadWeapons() async {
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
if (mounted) {
setState(() {
_weapons = weapons;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Mon Armurerie'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _weapons.isEmpty
? _buildEmptyState()
: _buildWeaponList(),
floatingActionButton: FloatingActionButton(
onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shield, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('Aucune arme enregistrée'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _showAddWeaponDialog,
child: const Text('Ajouter ma première arme'),
),
],
),
);
}
Widget _buildWeaponList() {
return ListView.builder(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
itemCount: _weapons.length,
itemBuilder: (context, index) {
final weapon = _weapons[index];
final accessories = _accessoryChips(weapon);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)),
);
_loadWeapons(); // Reload in case it was edited or maintenance was added
},
onLongPress: () => _confirmDelete(weapon),
child: Padding(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text(
'${weapon.type.displayName}${weapon.caliber}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
],
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
),
);
},
);
}
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!));
}
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
items.add((Icons.volume_off, weapon.silencer!));
}
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
items.add((Icons.touch_app, weapon.trigger!));
}
return items.map((item) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
const SizedBox(width: 4),
Text(item.$2, style: const TextStyle(fontSize: 12)),
],
),
);
}).toList();
}
void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController();
final caliberController = TextEditingController();
final magCountController = TextEditingController(text: '2');
final magCapController = TextEditingController(text: '15');
WeaponType selectedType = WeaponType.handgun;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter une arme'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
),
DropdownButtonFormField<WeaponType>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'),
),
Row(
children: [
Expanded(
child: TextField(
controller: magCountController,
decoration: const InputDecoration(labelText: 'Chargeurs'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: magCapController,
decoration: const InputDecoration(labelText: 'Capacité'),
keyboardType: TextInputType.number,
),
),
],
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
],
),
),
);
if (result == true && nameController.text.isNotEmpty) {
await repository.addWeapon(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
);
if (!mounted) return;
_loadWeapons();
}
}
void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true) {
await repository.deleteWeapon(weapon.id);
if (!mounted) return;
_loadWeapons();
}
}
}

View File

@@ -11,7 +11,13 @@ import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
/// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
/// tant qu'on ne tire pas manuellement pour rafraîchir).
final int refreshTick;
const HistoryScreen({super.key, this.refreshTick = 0});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
@@ -31,18 +37,36 @@ class _HistoryScreenState extends State<HistoryScreen> {
_loadSessions();
}
@override
void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
if (oldWidget.refreshTick != widget.refreshTick) {
_loadSessions();
}
}
Future<void> _loadSessions() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final repository = context.read<SessionRepository>();
final sessions = await repository.getAllSessions(targetType: _filterType);
final sessions = await repository.getAllSessions();
if (mounted) {
setState(() {
_sessions = sessions;
// --- FILTRAGE PAR TYPE DE CIBLE ---
// Une session est retenue si au moins une de ses cibles est du type choisi.
if (_filterType != null) {
_sessions = _sessions
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
.toList();
}
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) {
_sessions = _sessions.where((s) {
@@ -95,12 +119,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.dark(
primary: AppTheme.primaryColor,
onPrimary: Colors.white,
surface: Theme.of(context).cardColor,
onSurface: Colors.white,
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppTheme.primaryColor, // En-tête et sélection
onPrimary: Colors.white, // Texte sur en-tête/sélection
surface: Colors.white, // Fond du calendrier
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
),
child: child!,
@@ -120,17 +149,29 @@ class _HistoryScreenState extends State<HistoryScreen> {
appBar: AppBar(
title: const Text('Historique'),
actions: [
PopupMenuButton<TargetType?>(
icon: const Icon(Icons.filter_list),
onSelected: (type) {
setState(() => _filterType = type);
// NOTE : on passe par un String sentinelle ('all') car un
// PopupMenuItem avec value null ne déclenche jamais onSelected
// (Flutter l'interprète comme une annulation du menu).
PopupMenuButton<String>(
icon: Icon(
Icons.filter_list,
// Icône colorée quand un filtre est actif, pour le rendre visible.
color: _filterType != null ? AppTheme.primaryColor : null,
),
onSelected: (value) {
setState(() {
_filterType =
value == 'all' ? null : TargetType.fromString(value);
});
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(value: null, child: Text('Tous')),
const PopupMenuItem(value: 'all', child: Text('Tous')),
...TargetType.values.map(
(type) =>
PopupMenuItem(value: type, child: Text(type.displayName)),
(type) => PopupMenuItem(
value: type.name,
child: Text(type.displayName),
),
),
],
),

View File

@@ -1,10 +1,3 @@
/// Écran de détail d'une session.
///
/// Affiche la visualisation complète d'une session sauvegardée :
/// image de la cible avec overlay des impacts, scores recalculés,
/// statistiques de groupement et lien vers les statistiques détaillées.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -12,6 +5,8 @@ import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
@@ -20,7 +15,7 @@ import '../analysis/widgets/score_card.dart';
import '../analysis/widgets/grouping_stats.dart';
import '../statistics/statistics_screen.dart';
class SessionDetailScreen extends StatelessWidget {
class SessionDetailScreen extends StatefulWidget {
final Session session;
const SessionDetailScreen({
@@ -28,33 +23,40 @@ class SessionDetailScreen extends StatelessWidget {
required this.session,
});
@override
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
}
class _SessionDetailScreenState extends State<SessionDetailScreen> {
int _currentTargetIndex = 0;
@override
Widget build(BuildContext context) {
final scoreCalculator = context.read<ScoreCalculatorService>();
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
final currentAnalysis = widget.session.analyses[_currentTargetIndex];
final scoreResult = scoreCalculator.calculateScores(
shots: session.shots,
targetType: session.targetType,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
shots: currentAnalysis.shots,
targetType: currentAnalysis.targetType,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
targetRadius: currentAnalysis.targetRadius ?? 0.4,
);
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots);
final groupingResult = groupingAnalyzer.analyzeGrouping(currentAnalysis.shots);
return Scaffold(
appBar: AppBar(
title: Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
),
title: Text(widget.session.weapon),
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StatisticsScreen(singleSession: session),
builder: (_) => StatisticsScreen(singleSession: widget.session),
),
),
tooltip: 'Statistiques',
@@ -70,15 +72,22 @@ class SessionDetailScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Weapon and session overview
_buildSessionHeader(context),
// Target selector if multiple targets
if (widget.session.targetCount > 1)
_buildTargetSelector(),
// Target image with overlay
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
if (File(session.imagePath).existsSync())
if (File(currentAnalysis.imagePath).existsSync())
Image.file(
File(session.imagePath),
File(currentAnalysis.imagePath),
fit: BoxFit.contain,
)
else
@@ -89,14 +98,14 @@ class SessionDetailScreen extends StatelessWidget {
),
),
TargetOverlay(
shots: session.shots,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
targetType: session.targetType,
groupingCenterX: session.groupingCenterX,
groupingCenterY: session.groupingCenterY,
groupingDiameter: session.groupingDiameter,
shots: currentAnalysis.shots,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
targetRadius: currentAnalysis.targetRadius ?? 0.4,
targetType: currentAnalysis.targetType,
groupingCenterX: currentAnalysis.groupingCenterX,
groupingCenterY: currentAnalysis.groupingCenterY,
groupingDiameter: currentAnalysis.groupingDiameter,
),
],
),
@@ -107,31 +116,37 @@ class SessionDetailScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Session info
_buildSessionInfo(context),
// Analysis Info
_buildAnalysisInfo(context, currentAnalysis),
const SizedBox(height: 12),
// Score card
// Score card for current target
ScoreCard(
totalScore: session.totalScore,
shotCount: session.shotCount,
totalScore: currentAnalysis.totalScore,
shotCount: currentAnalysis.shotCount,
scoreResult: scoreResult,
targetType: session.targetType,
targetType: currentAnalysis.targetType,
),
const SizedBox(height: 12),
// Grouping stats
if (session.shotCount > 1)
// Grouping stats for current target
if (currentAnalysis.shotCount > 1)
GroupingStats(
groupingResult: groupingResult,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
),
// Notes
if (session.notes != null && session.notes!.isNotEmpty) ...[
// Notes for current target
if (currentAnalysis.notes != null && currentAnalysis.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context),
_buildNotesCard(context, currentAnalysis.notes!),
],
// Session notes
if (widget.session.notes != null && widget.session.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context, widget.session.notes!, title: 'Notes de session'),
],
],
),
@@ -142,16 +157,83 @@ class SessionDetailScreen extends StatelessWidget {
);
}
Widget _buildSessionInfo(BuildContext context) {
Widget _buildSessionHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat('EEEE dd MMMM yyyy', 'fr_FR').format(widget.session.createdAt),
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
DateFormat('HH:mm').format(widget.session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Score Total: ${widget.session.totalScore}',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryColor),
),
Text(
'${widget.session.totalShots} tirs au total',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
],
),
);
}
Widget _buildTargetSelector() {
return Container(
height: 50,
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: widget.session.targetCount,
itemBuilder: (context, index) {
final isSelected = _currentTargetIndex == index;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text('Cible ${index + 1}'),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _currentTargetIndex = index);
}
},
),
);
},
),
);
}
Widget _buildAnalysisInfo(BuildContext context, TargetAnalysis analysis) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
session.targetType == session.targetType
? Icons.track_changes
: Icons.person,
analysis.targetType == TargetType.concentric ? Icons.track_changes : Icons.person,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
@@ -160,14 +242,11 @@ class SessionDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.targetType.displayName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
analysis.targetType.displayName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR')
.format(session.createdAt),
'Cible ${_currentTargetIndex + 1} sur ${widget.session.targetCount}',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -179,7 +258,7 @@ class SessionDetailScreen extends StatelessWidget {
);
}
Widget _buildNotesCard(BuildContext context) {
Widget _buildNotesCard(BuildContext context, String notes, {String title = 'Notes'}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
@@ -188,18 +267,16 @@ class SessionDetailScreen extends StatelessWidget {
children: [
Row(
children: [
const Icon(Icons.notes, color: AppTheme.primaryColor),
const Icon(Icons.notes, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const Divider(),
Text(session.notes!),
Text(notes),
],
),
),
@@ -211,7 +288,7 @@ class SessionDetailScreen extends StatelessWidget {
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Voulez-vous vraiment supprimer cette session?'),
content: const Text('Voulez-vous vraiment supprimer cette session entière ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -231,20 +308,17 @@ class SessionDetailScreen extends StatelessWidget {
if (confirmed == true && context.mounted) {
try {
final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id);
await repository.deleteSession(widget.session.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
const SnackBar(content: Text('Session supprimée')),
);
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
);
}
}

View File

@@ -155,7 +155,7 @@ class HistoryChart extends StatelessWidget {
return touchedSpots.map((spot) {
final session = displaySessions[spot.x.toInt()];
return LineTooltipItem(
'Score: ${session.totalScore}\n${session.shotCount} tirs',
'Score: ${session.totalScore}\n${session.totalShots} tirs',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,

View File

@@ -1,15 +1,8 @@
/// Item de liste représentant une session.
///
/// Affiche les informations clés d'une session (date, type de cible,
/// score, nombre de tirs) avec miniature de l'image et actions de suppression.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/session.dart';
import '../../../data/models/target_type.dart';
class SessionListItem extends StatelessWidget {
final Session session;
@@ -33,7 +26,7 @@ class SessionListItem extends StatelessWidget {
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Thumbnail
// Thumbnail (from first target)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
@@ -51,14 +44,14 @@ class SessionListItem extends StatelessWidget {
children: [
Row(
children: [
Icon(
_getTargetIcon(),
const Icon(
Icons.shield,
size: 16,
color: AppTheme.primaryColor,
),
const SizedBox(width: 4),
Text(
session.targetType.displayName,
session.weapon,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
@@ -70,18 +63,19 @@ class SessionListItem extends StatelessWidget {
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
if (session.notes != null && session.notes!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
session.notes!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${session.targetCount} cible(s) • ${session.distance}m',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
],
),
),
@@ -98,7 +92,7 @@ class SessionListItem extends StatelessWidget {
),
),
Text(
'${session.shotCount} tirs',
'${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -120,7 +114,9 @@ class SessionListItem extends StatelessWidget {
}
Widget _buildThumbnail() {
final file = File(session.imagePath);
if (session.analyses.isEmpty) return _buildPlaceholder();
final file = File(session.analyses.first.imagePath);
if (file.existsSync()) {
return Image.file(
@@ -137,18 +133,9 @@ class SessionListItem extends StatelessWidget {
return Container(
color: Colors.grey[200],
child: Icon(
_getTargetIcon(),
Icons.track_changes,
color: Colors.grey[400],
),
);
}
IconData _getTargetIcon() {
switch (session.targetType) {
case TargetType.concentric:
return Icons.track_changes;
case TargetType.silhouette:
return Icons.person;
}
}
}

View File

@@ -6,6 +6,11 @@ import '../../data/repositories/session_repository.dart';
import '../capture/capture_screen.dart';
import '../history/history_screen.dart';
import '../statistics/statistics_screen.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../data/models/session.dart';
import '../settings/settings_screen.dart';
import '../session/session_setup_screen.dart';
import '../session/session_provider.dart';
import 'widgets/stats_card.dart';
class HomeScreen extends StatefulWidget {
@@ -17,20 +22,55 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
Map<String, dynamic>? _stats;
List<Session> _recentSessions = [];
bool _isLoading = true;
SessionProvider? _sessionProvider;
bool _wasSessionActive = false;
@override
void initState() {
super.initState();
_loadStats();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final provider = context.read<SessionProvider>();
if (provider != _sessionProvider) {
_sessionProvider?.removeListener(_onSessionChanged);
_sessionProvider = provider;
_sessionProvider!.addListener(_onSessionChanged);
_wasSessionActive = provider.isSessionActive;
}
}
void _onSessionChanged() {
final isActive = _sessionProvider?.isSessionActive ?? false;
// Quand une session vient de se terminer, on rafraîchit les stats
// automatiquement pour prendre en compte la dernière session.
if (_wasSessionActive && !isActive) {
_loadStats();
}
_wasSessionActive = isActive;
}
@override
void dispose() {
_sessionProvider?.removeListener(_onSessionChanged);
super.dispose();
}
Future<void> _loadStats() async {
final repository = context.read<SessionRepository>();
final stats = await repository.getStatistics();
final sessions = await repository.getAllSessions();
if (mounted) {
setState(() {
_stats = stats;
_recentSessions = sessions;
_isLoading = false;
});
}
@@ -40,7 +80,6 @@ class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// --- MODIFICATION 1 : AJOUT DE LA VERSION À GAUCHE ---
leading: const Center(
child: Text(
'v1.0.2',
@@ -50,14 +89,9 @@ class _HomeScreenState extends State<HomeScreen> {
title: const Text('Bully'),
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () => _navigateToStatistics(context),
tooltip: 'Statistiques',
),
IconButton(
icon: const Icon(Icons.history),
onPressed: () => _navigateToHistory(context),
tooltip: 'Historique',
icon: const Icon(Icons.settings),
onPressed: () => _navigateToSettings(context),
tooltip: 'Paramètres',
),
],
),
@@ -69,15 +103,10 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App logo/header
_buildHeader(),
const SizedBox(height: AppConstants.largePadding),
// Main action button
_buildMainActionButton(context),
const SizedBox(height: AppConstants.largePadding),
// Statistics section
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_stats != null)
@@ -95,7 +124,7 @@ class _HomeScreenState extends State<HomeScreen> {
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
@@ -124,21 +153,103 @@ class _HomeScreenState extends State<HomeScreen> {
}
Widget _buildMainActionButton(BuildContext context) {
return ElevatedButton.icon(
onPressed: () => _navigateToCapture(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
final sessionProvider = context.watch<SessionProvider>();
final isSessionActive = sessionProvider.isSessionActive;
// Si aucune session n'est active, on affiche juste le bouton de départ habituel
if (!isSessionActive) {
return ElevatedButton.icon(
onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
),
icon: const Icon(Icons.add_a_photo, size: 28),
label: const Text(
'Nouvelle Analyse',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
icon: const Icon(Icons.add_circle_outline, size: 28),
label: const Text(
'Nouvelle session',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
);
}
// MODIFICATION : Alignement sur une seule ligne (80% / 20%) avec une croix pour l'annulation
return Row(
children: [
// 80% de largeur pour continuer la session
Expanded(
flex: 8,
child: ElevatedButton.icon(
onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.play_circle_outline, size: 28),
label: const Text(
'Continuer la session',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 12),
// 20% de largeur pour terminer/annuler la session (Bouton Croix)
Expanded(
flex: 2,
child: OutlinedButton(
onPressed: () {
// Boite de dialogue de confirmation pour éviter les erreurs
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Terminer la session ?'),
content: const Text('Êtes-vous sûr de vouloir clôturer la session en cours ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('ANNULER'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
// Clôture propre de la session dans le State global
context.read<SessionProvider>().endSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Session terminée avec succès !'),
backgroundColor: AppTheme.successColor,
),
);
},
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
),
],
);
},
);
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 20), // Même hauteur que le bouton continuer
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
child: const Icon(Icons.close, size: 28),
),
),
],
);
}
@@ -153,8 +264,6 @@ class _HomeScreenState extends State<HomeScreen> {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
// Première ligne de vignettes (Sessions et Tirs)
Row(
children: [
Expanded(
@@ -180,30 +289,38 @@ class _HomeScreenState extends State<HomeScreen> {
),
],
),
// --- MODIFICATION 2 : AJOUT DU GRAPHIQUE AU MILIEU ---
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
height: 150,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Icon(Icons.show_chart, size: 50, color: Colors.grey),
),
child: _recentSessions.length < 2
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.show_chart, size: 40, color: Colors.grey),
Text(
'Pas assez de données',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
)
: _buildHomeTrendChart(),
),
),
// Deuxième ligne de vignettes (Historique et Meilleur)
Row(
children: [
Expanded(
@@ -233,13 +350,19 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
// --- MÉTHODES DE NAVIGATION ---
void _navigateToCapture(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
void _navigateToSessionSetup(BuildContext context) async {
final sessionProvider = context.read<SessionProvider>();
if (sessionProvider.isSessionActive) {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
} else {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SessionSetupScreen()),
);
}
_loadStats();
}
@@ -258,4 +381,105 @@ class _HomeScreenState extends State<HomeScreen> {
);
_loadStats();
}
}
Widget _buildHomeTrendChart() {
final sorted = List<Session>.from(_recentSessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final display =
sorted.length > 7 ? sorted.sublist(sorted.length - 7) : sorted;
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) => FlLine(
color: Colors.grey.withValues(alpha: 0.1),
strokeWidth: 1,
),
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= display.length) return const SizedBox();
final date = display[index].createdAt;
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'${date.day}/${date.month}',
style: TextStyle(
fontSize: 9,
color: Colors.grey.withValues(alpha: 0.8),
),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: TextStyle(
fontSize: 9,
color: Colors.grey.withValues(alpha: 0.8),
),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
left: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
),
),
lineBarsData: [
LineChartBarData(
spots: display.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.averageScore);
}).toList(),
isCurved: true,
color: AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: 3,
color: Colors.white,
strokeWidth: 2,
strokeColor: AppTheme.primaryColor,
),
),
belowBarData: BarAreaData(
show: true,
color: AppTheme.primaryColor.withValues(alpha: 0.1),
),
),
],
),
);
}
void _navigateToSettings(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
_loadStats();
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/foundation.dart';
import '../../data/models/target_analysis.dart';
class SessionProvider extends ChangeNotifier {
DateTime? _sessionDate;
DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName;
String? _currentWeaponId;
int _shotsPerTarget = 5;
int _distance = 25;
String? _activeSessionId;
final List<TargetAnalysis> _currentAnalyses = [];
bool _isSessionActive = false;
String? get currentWeapon => _currentWeaponName;
String? get currentWeaponId => _currentWeaponId;
int get shotsPerTarget => _shotsPerTarget;
int get distance => _distance;
bool get isSessionActive => _isSessionActive;
String? get activeSessionId => _activeSessionId;
List<TargetAnalysis> get currentAnalyses => List.unmodifiable(_currentAnalyses);
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) {
_currentWeaponName = weaponName;
_currentWeaponId = weaponId;
_shotsPerTarget = shots;
_distance = distance;
_sessionDate = date ?? DateTime.now();
_activeSessionId = sessionId;
_currentAnalyses.clear();
_isSessionActive = true;
notifyListeners();
}
void addAnalysis(TargetAnalysis analysis) {
_currentAnalyses.add(analysis);
notifyListeners();
}
void endSession() {
_isSessionActive = false;
_activeSessionId = null;
_currentWeaponId = null;
_currentWeaponName = null;
_currentAnalyses.clear();
_distance = 25;
notifyListeners();
}
}

View File

@@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'session_provider.dart';
import '../capture/capture_screen.dart';
import '../garage/weapon_list_screen.dart';
class SessionSetupScreen extends StatefulWidget {
const SessionSetupScreen({super.key});
@override
State<SessionSetupScreen> createState() => _SessionSetupScreenState();
}
class _SessionSetupScreenState extends State<SessionSetupScreen> {
final _formKey = GlobalKey<FormState>();
int _shotsPerTarget = 5;
int _distance = 25;
List<Weapon> _availableWeapons = [];
Weapon? _selectedWeapon;
bool _isLoadingWeapons = true;
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_loadWeapons();
}
Future<void> _loadWeapons() async {
setState(() => _isLoadingWeapons = true);
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
if (mounted) {
setState(() {
_availableWeapons = weapons;
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
_selectedWeapon = _availableWeapons.first;
_updateSettingsForWeapon(_selectedWeapon!);
}
_isLoadingWeapons = false;
});
}
}
void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
}
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'), // Force le calendrier en français
);
if (picked != null && picked != _selectedDate) {
setState(() {
// On garde aussi l'heure actuelle lors du changement de date
final now = DateTime.now();
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
});
}
}
void _startSession() {
if (_formKey.currentState!.validate() && _selectedWeapon != null) {
final repository = context.read<SessionRepository>();
final sessionProvider = context.read<SessionProvider>();
final sessionId = repository.generateId();
// PASSE-PARTOUT : On envoie la session au provider.
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
sessionProvider.startSession(
_selectedWeapon!.displayName,
_shotsPerTarget,
sessionId,
weaponId: _selectedWeapon!.id,
distance: _distance,
date: _selectedDate,
);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
}
}
@override
Widget build(BuildContext context) {
// Petit formatage sympa en français (ex: "27 mai 2026")
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
return Scaffold(
appBar: AppBar(
title: const Text('Configuration de la session'),
),
body: _isLoadingWeapons
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Informations générales',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
if (_availableWeapons.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
),
child: Column(
children: [
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
const SizedBox(height: 8),
const Text(
'Ton armurerie est vide.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
const Text(
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
).then((_) {
_loadWeapons();
});
},
icon: const Icon(Icons.add),
label: const Text('ALLER À MON ARMURERIE'),
),
],
),
)
else
Column(
children: [
DropdownButtonFormField<Weapon>(
initialValue: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
items: _availableWeapons.map((w) => DropdownMenuItem(
value: w,
child: Text(w.displayName),
)).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedWeapon = value;
_updateSettingsForWeapon(value);
});
}
},
),
const SizedBox(height: 16),
// NOUVEAU CHAMP : Sélecteur de date interactif
InkWell(
onTap: _availableWeapons.isEmpty ? null : _pickDate,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: IgnorePointer(
child: TextFormField(
decoration: InputDecoration(
labelText: 'Date de la session',
prefixIcon: const Icon(Icons.calendar_today),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
controller: TextEditingController(text: formattedDate),
),
),
),
],
),
const SizedBox(height: 24),
// Distance selector
const Text(
'Distance de tir (mètres)',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
label: '${_distance}m',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_distance = value.round();
});
},
),
),
Container(
width: 70,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${_distance}m',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.secondaryColor,
),
),
),
],
),
const SizedBox(height: 24),
// Shots per target
const Text(
'Nombre de balles pour la cible actuelle',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
label: '$_shotsPerTarget',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_shotsPerTarget = value.round();
});
},
),
),
Container(
width: 50,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_shotsPerTarget',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
),
],
),
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.play_arrow),
label: const Text(
'DÉMARRER LA SESSION',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,478 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../../core/theme/theme_provider.dart';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../services/wallet_identity_service.dart';
import '../garage/weapon_list_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final WalletIdentityService _walletService = WalletIdentityService();
String? _identityPhrase;
int? _photoCount;
bool _isLoadingStats = false;
bool _isUploadEnabled = false;
@override
void initState() {
super.initState();
_loadIdentity();
}
Future<void> _loadIdentity() async {
final phrase = await _walletService.getIdentityPhrase();
final isEnabled = await _walletService.isUploadEnabled();
if (mounted) {
setState(() {
_identityPhrase = phrase;
_isUploadEnabled = isEnabled;
});
_fetchStats(phrase);
}
}
Future<void> _fetchStats(String phrase) async {
setState(() => _isLoadingStats = true);
try {
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost
final baseUrl = Theme.of(context).platform == TargetPlatform.android
? 'http://10.0.2.2:3000'
: 'http://localhost:3000';
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (mounted) {
setState(() {
_photoCount = data['stats']['photo_count'] ?? 0;
_isLoadingStats = false;
});
}
} else {
if (mounted) setState(() => _isLoadingStats = false);
}
} catch (e) {
debugPrint('Erreur lors du chargement des statistiques: $e');
if (mounted) setState(() => _isLoadingStats = false);
}
}
void _copyToClipboard() {
if (_identityPhrase != null) {
Clipboard.setData(ClipboardData(text: _identityPhrase!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Identité copiée dans le presse-papiers'),
backgroundColor: AppTheme.successColor,
),
);
}
}
void _showThemeDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Apparence'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto),
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode),
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) {
final themeProvider = context.watch<ThemeProvider>();
final isSelected = themeProvider.themeMode == mode;
return ListTile(
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
title: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.primaryColor : null,
)),
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
onTap: () {
themeProvider.setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showIdentityDialog() {
if (_identityPhrase == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Votre Identité Unique', textAlign: TextAlign.center),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.security, size: 48, color: AppTheme.primaryColor),
const SizedBox(height: 16),
const Text(
'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.primaryColor.withAlpha(100)),
),
child: Text(
_identityPhrase!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
height: 1.5,
),
textAlign: TextAlign.center,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier'),
onPressed: () {
_copyToClipboard();
Navigator.pop(context);
},
),
],
),
);
}
void _showOptInDisclaimer(bool value) {
if (!value) {
// Si on désactive, pas besoin de disclaimer, on le fait direct.
_walletService.setUploadEnabled(false);
setState(() {
_isUploadEnabled = false;
});
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
content: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
SizedBox(height: 16),
Text(
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles à notre serveur sécurisé.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 12),
Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique incassable.',
style: TextStyle(fontSize: 13),
),
SizedBox(height: 12),
Text(
'🎁 Avantages Futurs : Votre contribution (Hash de Wallet) sera comptabilisée. Lors de la sortie publique de notre IA, les participants actifs recevront des fonctionnalités premium ou des badges exclusifs en récompense !',
style: TextStyle(fontSize: 13),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Refuser', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
onPressed: () {
_walletService.setUploadEnabled(true);
setState(() {
_isUploadEnabled = true;
});
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Merci pour votre contribution !'),
backgroundColor: AppTheme.successColor,
),
);
},
child: const Text('J\'accepte', style: TextStyle(color: Colors.white)),
),
],
),
);
}
// Rapport de bug autonome : on collecte une description + les infos
// techniques, et on copie un rapport prêt à coller dans un email de support.
void _showReportBugDialog() {
final descController = TextEditingController();
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
const appVersion = '1.0.0';
final platform = Theme.of(context).platform.name;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler un bug'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Décrivez le problème : ce que vous faisiez, ce qui était attendu et ce qui s\'est passé.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
TextField(
controller: descController,
maxLines: 5,
decoration: const InputDecoration(
hintText: 'Ex: l\'application se ferme quand j\'ouvre une session...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
'Infos techniques jointes : version $appVersion$platform',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier le rapport'),
onPressed: () {
final desc = descController.text.trim();
final report = StringBuffer()
..writeln('--- Rapport de bug ---')
..writeln('Version : $appVersion')
..writeln('Plateforme : $platform')
..writeln('Date : ${DateTime.now().toIso8601String()}')
..writeln('')
..writeln('Description :')
..writeln(desc.isEmpty ? '(non renseignée)' : desc);
Clipboard.setData(ClipboardData(text: report.toString()));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Rapport copié — collez-le dans un email à $supportEmail'),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 5),
),
);
},
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Paramètres'),
),
body: ListView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
children: [
_buildSectionHeader('Identité'),
_buildSettingsTile(
context: context,
icon: Icons.fingerprint,
title: 'Identité Wallet',
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
onTap: _showIdentityDialog,
),
Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return _buildSettingsTile(
context: context,
icon: Icons.color_lens_outlined,
title: 'Apparence',
subtitle: themeProvider.themeModeName,
onTap: _showThemeDialog,
);
},
),
const SizedBox(height: 24),
_buildSectionHeader('Gestion'),
_buildSettingsTile(
context: context,
icon: Icons.shield_outlined,
title: 'Mon Armurerie',
subtitle: 'Gérer mes armes et équipements',
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
);
},
),
const SizedBox(height: 24),
_buildSectionHeader('Configuration Serveur IA'),
Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled,
activeThumbColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onChanged: _showOptInDisclaimer,
),
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.cloud_outlined,
title: 'Adresse du Serveur IA',
subtitle: 'http://localhost:3000/api/upload',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Changement d\'adresse à venir')),
);
},
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.data_usage,
title: 'Photos Exportées',
subtitle: _isLoadingStats
? 'Chargement...'
: (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
onTap: () {
if (_identityPhrase != null) {
_fetchStats(_identityPhrase!);
}
},
),
const SizedBox(height: 24),
_buildSectionHeader('À propos'),
_buildSettingsTile(
context: context,
icon: Icons.info_outline,
title: 'Version de l\'application',
subtitle: '1.0.0',
onTap: () {},
),
_buildSettingsTile(
context: context,
icon: Icons.bug_report_outlined,
title: 'Signaler un bug',
subtitle: 'Aidez-nous à corriger les problèmes',
onTap: _showReportBugDialog,
),
_buildSettingsTile(
context: context,
icon: Icons.privacy_tip_outlined,
title: 'Politique de confidentialité',
onTap: () {},
),
],
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
child: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
);
}
Widget _buildSettingsTile({
required BuildContext context,
required IconData icon,
required String title,
String? subtitle,
required VoidCallback onTap,
}) {
return Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
leading: Icon(icon, color: AppTheme.textPrimary),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null,
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onTap: onTap,
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,11 @@ import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'app.dart';
import 'core/theme/theme_provider.dart';
import 'data/repositories/session_repository.dart';
import 'services/target_detection_service.dart';
import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart';
import 'services/image_processing_service.dart';
import 'features/session/session_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -30,14 +30,6 @@ void main() async {
runApp(
MultiProvider(
providers: [
Provider<ImageProcessingService>(
create: (_) => ImageProcessingService(),
),
Provider<TargetDetectionService>(
create: (context) => TargetDetectionService(
imageProcessingService: context.read<ImageProcessingService>(),
),
),
Provider<ScoreCalculatorService>(
create: (_) => ScoreCalculatorService(),
),
@@ -45,6 +37,8 @@ void main() async {
create: (_) => GroupingAnalyzerService(),
),
Provider<SessionRepository>(create: (_) => SessionRepository()),
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
],
child: const BullyApp(),
),

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'features/home/home_screen.dart';
import 'features/history/history_screen.dart';
import 'features/statistics/statistics_screen.dart';
import 'features/garage/weapon_list_screen.dart';
import 'core/theme/app_theme.dart';
class MainNavigationHolder extends StatefulWidget {
const MainNavigationHolder({super.key});
@override
State<MainNavigationHolder> createState() => _MainNavigationHolderState();
}
class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0;
// Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
// rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
// rafraîchissent pas seuls).
int _statsTick = 0;
int _historyTick = 0;
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
if (index == 1) _historyTick++;
if (index == 2) _statsTick++;
});
}
@override
Widget build(BuildContext context) {
final screens = [
const HomeScreen(),
HistoryScreen(refreshTick: _historyTick),
StatisticsScreen(refreshTick: _statsTick),
const WeaponListScreen(),
];
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: screens,
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
type: BottomNavigationBarType.fixed,
backgroundColor: Theme.of(context).cardColor,
selectedItemColor: AppTheme.primaryColor,
unselectedItemColor: Colors.grey,
showUnselectedLabels: true,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Accueil',
),
BottomNavigationBarItem(
icon: Icon(Icons.history_outlined),
activeIcon: Icon(Icons.history),
label: 'Historique',
),
BottomNavigationBarItem(
icon: Icon(Icons.analytics_outlined),
activeIcon: Icon(Icons.analytics),
label: 'Stats',
),
BottomNavigationBarItem(
icon: Icon(Icons.shield_outlined),
activeIcon: Icon(Icons.shield),
label: 'Armurerie',
),
],
),
),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:crypto/crypto.dart';
import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import 'wallet_identity_service.dart';
class AiExportService {
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
static String get _defaultApiUrl {
if (Platform.isAndroid) {
return 'http://10.0.2.2:3000/api/upload';
}
return 'http://localhost:3000/api/upload';
}
/// Extrait les informations de l'appareil
Future<Map<String, dynamic>> _getDeviceInfo() async {
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'};
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
deviceData['model'] = '${androidInfo.brand} ${androidInfo.model}';
deviceData['os'] = 'Android ${androidInfo.version.release}';
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
deviceData['model'] = iosInfo.name;
deviceData['os'] = '${iosInfo.systemName} ${iosInfo.systemVersion}';
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfoPlugin.windowsInfo;
deviceData['model'] = 'Windows PC';
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
}
} catch (e) {
debugPrint('Erreur lors de la récupération des infos appareil: $e');
}
return deviceData;
}
/// Exporte l'image et les données de plotting vers le serveur
Future<bool> exportData({
required String imagePath,
required String sessionId,
required TargetType targetType,
required double targetCenterX,
required double targetCenterY,
required double targetRadius,
required List<Shot> shots,
int distanceMeters = 25,
String weaponName = 'Unknown',
String? apiUrl,
}) async {
try {
final url = Uri.parse(apiUrl ?? _defaultApiUrl);
final request = http.MultipartRequest('POST', url);
// 1. Prepare image
final file = File(imagePath);
if (!await file.exists()) {
throw Exception('Le fichier image n\'existe pas');
}
// Read image metadata (approximate dimensions since decoding image can be heavy)
// On the frontend we usually have aspectRatio, here we use generic values if not available.
final deviceData = await _getDeviceInfo();
// We approximate the target corners from center and radius
// radius is relative (0 to 1). We need image width/height to get pixels.
// But we can just pass relative corners as well, or a normalized bounding box.
// Let's create normalized corners (0 to 1).
final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY + targetRadius},
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY + targetRadius},
];
// Format the impacts
final formattedImpacts = shots.asMap().entries.map((entry) {
final index = entry.key;
final shot = entry.value;
return {
"id": index + 1,
"label": "bullet_hole",
"score": shot.score,
"coords": {
"norm_x": shot.x,
"norm_y": shot.y
}
};
}).toList();
// Get and hash the wallet identity
final walletService = WalletIdentityService();
final phrase = await walletService.getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
// Build JSON payload
final plottingJson = {
"session_id": sessionId,
"wallet_hash": walletHash,
"timestamp": DateTime.now().toIso8601String(),
"device_info": deviceData,
"target_metadata": {
"type": targetType.name,
"distance_meters": distanceMeters,
"weapon": weaponName,
// The backend could extract exact width/height from the image.
},
"plotting": {
"target_corners": corners,
"impacts": formattedImpacts
}
};
// Add fields to request
request.fields['plotting'] = jsonEncode(plottingJson);
// Add file
request.files.add(
await http.MultipartFile.fromPath('photo', imagePath),
);
// Send request
final response = await request.send();
if (response.statusCode == 200) {
final responseData = await response.stream.bytesToString();
debugPrint('Export réussi: $responseData');
return true;
} else {
final errorData = await response.stream.bytesToString();
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
return false;
}
} catch (e) {
debugPrint('Exception lors de l\'export: $e');
return false;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,14 @@
/// Service de recadrage d'images.
///
/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder
/// dans un fichier temporaire pour utilisation ultérieure.
library;
import 'dart:isolate'; // AJOUT
import 'dart:io';
import 'dart:math' as math;
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
/// Représente une zone de recadrage normalisée (0.0 à 1.0)
class CropRect {
/// Position X du coin supérieur gauche (0.0 à 1.0)
final double x;
/// Position Y du coin supérieur gauche (0.0 à 1.0)
final double y;
/// Largeur de la zone (0.0 à 1.0)
final double width;
/// Hauteur de la zone (0.0 à 1.0)
final double height;
const CropRect({
@@ -35,97 +22,268 @@ class CropRect {
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
}
/// Service pour recadrer les images
// AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable)
class _CropParams {
final String sourcePath;
final double cropX;
final double cropY;
final double cropWidth;
final double cropHeight;
final double rotationDegrees;
final int outputSize;
final String outputPath;
_CropParams({
required this.sourcePath,
required this.cropX,
required this.cropY,
required this.cropWidth,
required this.cropHeight,
required this.rotationDegrees,
required this.outputSize,
required this.outputPath,
});
}
// AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static)
void _cropIsolateEntry(_CropParams params) {
final file = File(params.sourcePath);
final bytes = file.readAsBytesSync();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) {
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
}
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
if (params.rotationDegrees != 0.0) {
originalImage = img.copyRotate(
originalImage,
angle: params.rotationDegrees,
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
);
}
// Recadrage
final srcX = (params.cropX * originalImage.width).round();
final srcY = (params.cropY * originalImage.height).round();
final srcWidth = (params.cropWidth * originalImage.width).round();
final srcHeight = (params.cropHeight * originalImage.height).round();
final clampedX = srcX.clamp(0, originalImage.width - 1);
final clampedY = srcY.clamp(0, originalImage.height - 1);
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
img.Image cropped = img.copyCrop(
originalImage,
x: clampedX,
y: clampedY,
width: clampedWidth,
height: clampedHeight,
);
// Redimensionnement — LINEAR au lieu de CUBIC
if (cropped.width != params.outputSize || cropped.height != params.outputSize) {
cropped = img.copyResize(
cropped,
width: params.outputSize,
height: params.outputSize,
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
);
}
// Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable)
final outputFile = File(params.outputPath);
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
}
// AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir)
class _ViewportCropParams {
final String sourcePath;
final double offsetDx;
final double offsetDy;
final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain)
final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran
final double zoomScale; // zoom utilisateur : sert UNIQUEMENT au pointage
final double rotationDegrees;
final int outputSize;
final String outputPath;
_ViewportCropParams({
required this.sourcePath,
required this.offsetDx,
required this.offsetDy,
required this.displayPerSourcePx,
required this.cropSizeDisplay,
required this.zoomScale,
required this.rotationDegrees,
required this.outputSize,
required this.outputPath,
});
}
// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation
// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute
// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur
// l'a placée, même collée à un bord (aucun recentrage forcé).
void _viewportCropIsolateEntry(_ViewportCropParams p) {
final bytes = File(p.sourcePath).readAsBytesSync();
final img.Image? src = img.decodeImage(bytes);
if (src == null) {
throw Exception('Impossible de décoder l\'image: ${p.sourcePath}');
}
// Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels.
img.Image rotated = src;
if (p.rotationDegrees != 0.0) {
rotated = img.copyRotate(
src,
angle: p.rotationDegrees,
interpolation: img.Interpolation.linear,
);
}
final double f = p.displayPerSourcePx;
// Côté de la fenêtre de visée en pixels source. On utilise f SEUL (pas le
// zoom) → le champ de vision capturé est toujours celui de l'image non
// zoomée : le zoom n'est donc PAS pris en compte dans le rendu de sortie.
final int side = math.max(1, (p.cropSizeDisplay / f).round());
// Centre de la fenêtre de visée, en pixels de l'image (rotated).
// La translation écran s'applique APRÈS le zoom, donc un déplacement écran
// `offset` vaut `offset / (f * zoom)` pixels source. On inclut donc le zoom
// ICI (uniquement pour le pointage) afin que la croix vise le même point
// quel que soit le niveau de zoom.
final double fz = f * p.zoomScale;
final double cropCenterX = rotated.width / 2 - p.offsetDx / fz;
final double cropCenterY = rotated.height / 2 - p.offsetDy / fz;
final int srcX = (cropCenterX - side / 2).round();
final int srcY = (cropCenterY - side / 2).round();
// Toile carrée NOIRE opaque (numChannels 3 → pixels initialisés à 0 = noir).
final out = img.Image(width: side, height: side, numChannels: 3);
// Intersection de la fenêtre de visée avec l'image réelle. Tout ce qui
// déborde reste noir (padding) → aucun recentrage forcé.
final int vx0 = math.max(0, srcX);
final int vy0 = math.max(0, srcY);
final int vx1 = math.min(rotated.width, srcX + side);
final int vy1 = math.min(rotated.height, srcY + side);
final int vw = vx1 - vx0;
final int vh = vy1 - vy0;
if (vw > 0 && vh > 0) {
final region = img.copyCrop(rotated, x: vx0, y: vy0, width: vw, height: vh);
// dstW/dstH EXPLICITES = taille de la région → AUCUN redimensionnement
// (sinon compositeImage étire la source pour remplir la destination).
img.compositeImage(
out,
region,
dstX: vx0 - srcX,
dstY: vy0 - srcY,
dstW: vw,
dstH: vh,
);
}
img.Image result = out;
if (side != p.outputSize) {
result = img.copyResize(
out,
width: p.outputSize,
height: p.outputSize,
interpolation: img.Interpolation.linear,
);
}
File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85));
}
class ImageCropService {
final Uuid _uuid = const Uuid();
/// Taille de sortie maximale pour les images recadrées
static const int maxOutputSize = 1024;
/// Recadre une image en format carré
/// Découpe carrée calée sur la fenêtre de visée de l'écran de centrage.
/// Le décalage (pan) et la rotation sont respectés, le zoom est ignoré, et
/// tout débordement hors de l'image est rempli en noir (jamais de recentrage).
///
/// [sourcePath] - Chemin vers l'image source
/// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0)
/// [outputSize] - Taille de sortie en pixels (carré)
///
/// Retourne le chemin vers l'image recadrée dans le dossier temporaire
Future<String> cropToSquare(
String sourcePath,
CropRect cropRect, {
/// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran.
/// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain).
/// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran.
Future<String> cropViewport({
required String sourcePath,
required double offsetDx,
required double offsetDy,
required double displayPerSourcePx,
required double cropSizeDisplay,
double zoomScale = 1.0,
double rotationDegrees = 0.0,
int outputSize = maxOutputSize,
}) async {
// Charger l'image source
final file = File(sourcePath);
final bytes = await file.readAsBytes();
final originalImage = img.decodeImage(bytes);
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
if (originalImage == null) {
throw Exception('Impossible de décoder l\'image: $sourcePath');
}
// Calculer les coordonnées en pixels
final srcX = (cropRect.x * originalImage.width).round();
final srcY = (cropRect.y * originalImage.height).round();
final srcWidth = (cropRect.width * originalImage.width).round();
final srcHeight = (cropRect.height * originalImage.height).round();
// S'assurer que les dimensions sont valides
final clampedX = srcX.clamp(0, originalImage.width - 1);
final clampedY = srcY.clamp(0, originalImage.height - 1);
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
// Recadrer l'image
img.Image cropped = img.copyCrop(
originalImage,
x: clampedX,
y: clampedY,
width: clampedWidth,
height: clampedHeight,
final params = _ViewportCropParams(
sourcePath: sourcePath,
offsetDx: offsetDx,
offsetDy: offsetDy,
displayPerSourcePx: displayPerSourcePx,
cropSizeDisplay: cropSizeDisplay,
zoomScale: zoomScale <= 0 ? 1.0 : zoomScale,
rotationDegrees: rotationDegrees,
outputSize: outputSize,
outputPath: outputPath,
);
// Redimensionner à la taille de sortie si nécessaire
if (cropped.width != outputSize || cropped.height != outputSize) {
cropped = img.copyResize(
cropped,
width: outputSize,
height: outputSize,
interpolation: img.Interpolation.cubic,
);
}
// Traitement lourd dans un Isolate → thread UI fluide.
await Isolate.run(() => _viewportCropIsolateEntry(params));
// Sauvegarder dans le dossier temporaire
return outputPath;
}
Future<String> cropToSquare(
String sourcePath,
CropRect cropRect, {
double rotationDegrees = 0.0,
int outputSize = maxOutputSize,
}) async {
final tempDir = await getTemporaryDirectory();
final fileName = 'cropped_${_uuid.v4()}.jpg';
final outputPath = '${tempDir.path}/$fileName';
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
final outputFile = File(outputPath);
await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90));
final params = _CropParams(
sourcePath: sourcePath,
cropX: cropRect.x,
cropY: cropRect.y,
cropWidth: cropRect.width,
cropHeight: cropRect.height,
rotationDegrees: rotationDegrees,
outputSize: outputSize,
outputPath: outputPath,
);
// OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé
// → le thread UI reste fluide pendant le calcul
await Isolate.run(() => _cropIsolateEntry(params));
return outputPath;
}
/// Calcule la zone de recadrage carrée maximale centrée sur l'image
///
/// [imageWidth] - Largeur de l'image en pixels
/// [imageHeight] - Hauteur de l'image en pixels
///
/// Retourne un CropRect normalisé pour un carré centré
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
final aspectRatio = imageWidth / imageHeight;
if (aspectRatio > 1) {
// Image plus large que haute - centrer horizontalement
final squareWidth = imageHeight / imageWidth;
final x = (1 - squareWidth) / 2;
return CropRect(x: x, y: 0, width: squareWidth, height: 1);
} else if (aspectRatio < 1) {
// Image plus haute que large - centrer verticalement
final squareHeight = imageWidth / imageHeight;
final y = (1 - squareHeight) / 2;
return CropRect(x: 0, y: y, width: 1, height: squareHeight);
} else {
// Déjà carré
return const CropRect(x: 0, y: 0, width: 1, height: 1);
}
}
@@ -147,7 +305,39 @@ class ImageCropService {
}
}
} catch (e) {
// Ignorer les erreurs de nettoyage
// Ignorer les erreurs
}
}
}
/// Coupe automatiquement une photo brute de l'appareil photo en carré
/// calé sur la position haute du viseur de l'écran.
Future<String> cropRawCameraToSquare(String sourcePath) async {
final file = File(sourcePath);
final bytes = await file.readAsBytes();
final originalImage = img.decodeImage(bytes);
if (originalImage == null) return sourcePath;
final int imgW = originalImage.width;
final int imgH = originalImage.height;
final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round();
final int x = ((imgW - cropSizePixels) / 2).round();
final int y = ((imgH - cropSizePixels) / 2).round();
final squareImage = img.copyCrop(
originalImage,
x: x.clamp(0, imgW - 1),
y: y.clamp(0, imgH - 1),
width: cropSizePixels,
height: cropSizePixels,
);
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg';
await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85));
return outputPath;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,228 +0,0 @@
/// Service de détection d'impacts utilisant OpenCV.
library;
import 'dart:math' as math;
import 'package:opencv_dart/opencv_dart.dart' as cv;
/// Paramètres de détection d'impacts OpenCV
class OpenCVDetectionSettings {
/// Seuil Canny bas pour la détection de contours
final double cannyThreshold1;
/// Seuil Canny haut pour la détection de contours
final double cannyThreshold2;
/// Distance minimale entre les centres des cercles détectés
final double minDist;
/// Paramètre 1 de HoughCircles (seuil Canny interne)
final double param1;
/// Paramètre 2 de HoughCircles (seuil d'accumulation)
final double param2;
/// Rayon minimum des cercles en pixels
final int minRadius;
/// Rayon maximum des cercles en pixels
final int maxRadius;
/// Taille du flou gaussien (doit être impair)
final int blurSize;
/// Utiliser la détection de contours en plus de Hough
final bool useContourDetection;
/// Circularité minimale pour la détection par contours (0-1)
final double minCircularity;
/// Surface minimale des contours
final double minContourArea;
/// Surface maximale des contours
final double maxContourArea;
const OpenCVDetectionSettings({
this.cannyThreshold1 = 50,
this.cannyThreshold2 = 150,
this.minDist = 20,
this.param1 = 100,
this.param2 = 30,
this.minRadius = 5,
this.maxRadius = 50,
this.blurSize = 5,
this.useContourDetection = true,
this.minCircularity = 0.6,
this.minContourArea = 50,
this.maxContourArea = 5000,
});
}
/// Résultat de détection d'impact
class OpenCVDetectedImpact {
/// Position X normalisée (0-1)
final double x;
/// Position Y normalisée (0-1)
final double y;
/// Rayon en pixels
final double radius;
/// Score de confiance (0-1)
final double confidence;
/// Méthode de détection utilisée
final String method;
const OpenCVDetectedImpact({
required this.x,
required this.y,
required this.radius,
this.confidence = 1.0,
this.method = 'unknown',
});
}
/// Service de détection d'impacts utilisant OpenCV
class OpenCVImpactDetectionService {
/// Détecte les impacts dans une image en utilisant OpenCV
List<OpenCVDetectedImpact> detectImpacts(
String imagePath, {
OpenCVDetectionSettings settings = const OpenCVDetectionSettings(),
}) {
try {
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) return [];
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply blur to reduce noise
final blurKSize = (settings.blurSize, settings.blurSize);
final blurred = cv.gaussianBlur(gray, blurKSize, 2, sigmaY: 2);
final List<OpenCVDetectedImpact> detectedImpacts = [];
final circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
settings.minDist,
param1: settings.param1,
param2: settings.param2,
minRadius: settings.minRadius,
maxRadius: settings.maxRadius,
);
if (circles.rows > 0 && circles.cols > 0) {
// Mat shape: (1, N, 3) usually for HoughCircles (CV_32FC3)
// We use at<Vec3f> directly.
for (int i = 0; i < circles.cols; i++) {
final vec = circles.at<cv.Vec3f>(0, i);
final x = vec.val1;
final y = vec.val2;
final r = vec.val3;
detectedImpacts.add(
OpenCVDetectedImpact(
x: x / img.cols,
y: y / img.rows,
radius: r,
confidence: 0.8,
method: 'hough',
),
);
}
}
// 2. Contour Detection (if enabled)
if (settings.useContourDetection) {
// Canny edge detection
final edges = cv.canny(
blurred,
settings.cannyThreshold1,
settings.cannyThreshold2,
);
// Find contours
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
// hierarchy is $2
for (int i = 0; i < contours.length; i++) {
final contour = contours[i];
// Filter by area
final area = cv.contourArea(contour);
if (area < settings.minContourArea ||
area > settings.maxContourArea) {
continue;
}
// Filter by circularity
final perimeter = cv.arcLength(contour, true);
if (perimeter == 0) continue;
final circularity = 4 * math.pi * area / (perimeter * perimeter);
if (circularity < settings.minCircularity) continue;
// Get bounding circle
final enclosingCircle = cv.minEnclosingCircle(contour);
final center = enclosingCircle.$1;
final radius = enclosingCircle.$2;
// Avoid duplicates (simple distance check against Hough results)
bool isDuplicate = false;
for (final existing in detectedImpacts) {
final dx = existing.x * img.cols - center.x;
final dy = existing.y * img.rows - center.y;
final dist = math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
detectedImpacts.add(
OpenCVDetectedImpact(
x: center.x / img.cols,
y: center.y / img.rows,
radius: radius,
confidence: circularity, // Use circularity as confidence
method: 'contour',
),
);
}
}
}
return detectedImpacts;
} catch (e) {
// print('OpenCV Error: $e');
return [];
}
}
/// Détecte les impacts en utilisant une image de référence
List<OpenCVDetectedImpact> detectFromReferences(
String imagePath,
List<({double x, double y})> referencePoints, {
double tolerance = 2.0,
}) {
// Basic implementation: use average color/brightness of reference points
// This is a placeholder for a more complex template matching or feature matching
// For now, we can just run the standard detection but filter results
// based on properties of the reference points (e.g. size/radius if we had it).
// Returning standard detection for now to enable the feature.
return detectImpacts(imagePath);
}
}

View File

@@ -26,23 +26,34 @@ class TargetDetectionResult {
class OpenCVTargetService {
/// Detect the main target (center and radius) from an image file
///
/// IMPORTANT : les Mat OpenCV sont de la mémoire NATIVE, invisible pour le
/// garbage collector Dart. Cette méthode est appelée en boucle (~1 s)
/// pendant l'aperçu caméra : sans dispose() explicite dans le finally, la
/// mémoire native grimpe en continu tant que l'utilisateur vise.
Future<TargetDetectionResult> detectTarget(String imagePath) async {
cv.Mat? img;
cv.Mat? gray;
cv.Mat? blurred;
cv.Mat? circles;
cv.Mat? looseCircles;
try {
// Read image
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) {
return TargetDetectionResult.failure();
}
// Convert to grayscale
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply Gaussian blur to reduce noise
final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
// Detect circles using Hough Transform
// Parameters need to be tuned for the specific target type
final circles = cv.HoughCircles(
// Detect circles using Hough Transform.
// HoughCircles returns a Mat of shape (1, N) of Vec3f (x, y, r).
circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1, // dp
@@ -55,26 +66,9 @@ class OpenCVTargetService {
maxRadius: img.cols ~/ 2,
);
// HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles.
// In opencv_dart, we cannot iterate easily.
// However, we can access data via pointer if needed, or check if Vec3f is supported.
// Given the user report, `at<Vec3f>` likely failed compilation or runtime.
// Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...).
// Or use `at<double>` carefully.
// Better yet: try to use `circles.data` if available, but it returns a Pointer.
// Let's stick to `at` but use `double` and manual offset if Vec3f fails.
// actually, let's try to trust `at<double>` for flattened access OR `at<Vec3f>`.
// NOTE: `at<Vec3f>` was reported as "method at not defined for VecPoint2f" earlier, NOT for Mat.
// The user error was for `VecPoint2f`. `Mat` definitely has `at`.
// BUT `VecPoint2f` is a List-like structure in Dart wrapper.
// usage of `at` on `VecPoint2f` was the error.
// Here `circles` IS A MAT. So `at` IS defined.
// However, to be safe and robust, and to implement clustering...
if (circles.isEmpty) {
// Try with different parameters if first attempt fails (more lenient)
final looseCircles = cv.HoughCircles(
looseCircles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
@@ -93,8 +87,15 @@ class OpenCVTargetService {
return _findBestConcentricCircles(circles, img.cols, img.rows);
} catch (e) {
// print('Error detecting target with OpenCV: $e');
return TargetDetectionResult.failure();
} finally {
// _findBestConcentricCircles a déjà extrait les données dans des listes
// Dart avant qu'on arrive ici : libérer les Mat est donc toujours sûr.
img?.dispose();
gray?.dispose();
blurred?.dispose();
circles?.dispose();
looseCircles?.dispose();
}
}

View File

@@ -0,0 +1,161 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:sensors_plus/sensors_plus.dart';
/// Statut de parallélisme retourné en temps réel.
enum ParallelismStatus {
/// Pas encore de données capteur disponibles.
unknown,
/// L'appareil est bien parallèle à la cible.
aligned,
/// L'appareil est trop incliné.
misaligned,
}
/// Données de parallélisme calculées à chaque frame capteur.
class ParallelismData {
final ParallelismStatus status;
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
final double pitchDegrees;
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
final double rollDegrees;
const ParallelismData({
required this.status,
required this.pitchDegrees,
required this.rollDegrees,
});
bool get isAligned => status == ParallelismStatus.aligned;
@override
String toString() =>
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
}
/// Service de détection du parallélisme par accéléromètre.
///
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
///
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
/// État actuel = aligned → passe à misaligned si angle > [misalignThreshold]
///
/// Cela crée une "zone de confort" entre les deux seuils où le statut
/// ne change pas — le vert reste vert même si la main tremble légèrement.
///
/// Valeurs par défaut recommandées :
/// alignThreshold = 15° (seuil d'entrée dans le vert — assez souple)
/// misalignThreshold = 22° (seuil de sortie du vert — tolérant au tremblement)
class ParallelismService {
/// Angle max pour passer de misaligned → aligned.
final double alignThreshold;
/// Angle à dépasser pour passer de aligned → misaligned.
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
final double misalignThreshold;
StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast();
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
ParallelismService({
this.alignThreshold = 25.0,
this.misalignThreshold = 32.0,
}) : assert(
misalignThreshold > alignThreshold,
'misalignThreshold doit être supérieur à alignThreshold',
);
Stream<ParallelismData> get stream => _controller.stream;
void start() {
if (_subscription != null) return;
_subscription = accelerometerEventStream(
samplingPeriod: SensorInterval.normalInterval, // ~50 ms
).listen(
_onAccelerometerEvent,
onError: (_) {
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
if (!_controller.isClosed) {
_currentStatus = ParallelismStatus.unknown;
_controller.add(const ParallelismData(
status: ParallelismStatus.unknown,
pitchDegrees: 0,
rollDegrees: 0,
));
}
},
);
}
void stop() {
_subscription?.cancel();
_subscription = null;
_currentStatus = ParallelismStatus.unknown;
}
void dispose() {
stop();
_controller.close();
}
void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return;
final double gx = event.x;
final double gy = event.y;
final double gz = event.z;
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
if (magnitude < 1.0) return; // Données aberrantes
// Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude;
final double nz = gz / magnitude;
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
// Le critère de couleur = le PIRE des deux angles affichés à l'écran.
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert.
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs());
// ── Hystérésis ──────────────────────────────────────────────────────────
// Premier appel : on décide selon alignThreshold uniquement
if (_currentStatus == ParallelismStatus.unknown) {
_currentStatus = worstAngle <= alignThreshold
? ParallelismStatus.aligned
: ParallelismStatus.misaligned;
}
// Déjà misaligned → devient aligned seulement si on passe sous alignThreshold
else if (_currentStatus == ParallelismStatus.misaligned) {
if (worstAngle <= alignThreshold) {
_currentStatus = ParallelismStatus.aligned;
}
}
// Déjà aligned → devient misaligned seulement si on dépasse misalignThreshold
else if (_currentStatus == ParallelismStatus.aligned) {
if (worstAngle > misalignThreshold) {
_currentStatus = ParallelismStatus.misaligned;
}
// Entre alignThreshold et misalignThreshold → on garde le vert, on ne change rien
}
// ────────────────────────────────────────────────────────────────────────
_controller.add(ParallelismData(
status: _currentStatus,
pitchDegrees: pitchDeg,
rollDegrees: rollDeg,
));
}
}

View File

@@ -66,25 +66,12 @@ class PrecisionStats {
/// Standard deviation statistics
class StdDevStats {
/// Standard deviation of X positions
final double stdDevX;
/// Standard deviation of Y positions
final double stdDevY;
/// Combined standard deviation (radial)
final double stdDevRadial;
/// Standard deviation of scores
final double stdDevScore;
/// Mean X position
final double meanX;
/// Mean Y position
final double meanY;
/// Mean score
final double meanScore;
const StdDevStats({
@@ -98,18 +85,11 @@ class StdDevStats {
});
}
/// Regional distribution (quadrants or sectors)
/// Regional distribution
class RegionalStats {
/// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right)
final Map<String, int> quadrantDistribution;
/// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center)
final Map<String, int> sectorDistribution;
/// Dominant direction (where most shots land)
final String dominantDirection;
/// Bias offset from center
final double biasX;
final double biasY;
@@ -157,16 +137,16 @@ class StatisticsService {
SessionStatistics calculateStatistics(
List<Session> sessions, {
StatsPeriod period = StatsPeriod.all,
double targetCenterX = 0.5,
double targetCenterY = 0.5,
}) {
// Filter sessions by period
final filteredSessions = _filterByPeriod(sessions, period);
// Collect all shots
// Collect all shots from all analyses in all sessions
final allShots = <Shot>[];
for (final session in filteredSessions) {
allShots.addAll(session.shots);
for (final analysis in session.analyses) {
allShots.addAll(analysis.shots);
}
}
if (allShots.isEmpty) {
@@ -183,14 +163,14 @@ class StatisticsService {
// Calculate heat map
final heatMap = _calculateHeatMap(allShots, gridSize: 5);
// Calculate precision
final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY);
// Calculate precision (using relative center 0.5, 0.5)
final precision = _calculatePrecision(allShots, 0.5, 0.5);
// Calculate standard deviation
final stdDev = _calculateStdDev(allShots);
// Calculate regional distribution
final regional = _calculateRegional(allShots, targetCenterX, targetCenterY);
final regional = _calculateRegional(allShots, 0.5, 0.5);
return SessionStatistics(
totalShots: totalShots,
@@ -224,20 +204,13 @@ class StatisticsService {
/// Calculate heat map
HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) {
// Initialize grid
final grid = List.generate(
gridSize,
(_) => List.generate(gridSize, (_) => <Shot>[]),
);
// Assign shots to grid cells
final grid = List.generate(gridSize, (_) => List.generate(gridSize, (_) => <Shot>[]));
for (final shot in shots) {
final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1);
final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1);
grid[row][col].add(shot);
}
// Find max count for normalization
int maxCount = 0;
for (final row in grid) {
for (final cell in row) {
@@ -245,16 +218,12 @@ class StatisticsService {
}
}
// Create heat zones
final zones = <List<HeatZone>>[];
for (int row = 0; row < gridSize; row++) {
final rowZones = <HeatZone>[];
for (int col = 0; col < gridSize; col++) {
final cellShots = grid[row][col];
final avgScore = cellShots.isEmpty
? 0.0
: cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
final avgScore = cellShots.isEmpty ? 0.0 : cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
rowZones.add(HeatZone(
row: row,
col: col,
@@ -266,30 +235,15 @@ class StatisticsService {
zones.add(rowZones);
}
return HeatMap(
gridSize: gridSize,
zones: zones,
maxShotsInZone: maxCount,
totalShots: shots.length,
);
return HeatMap(gridSize: gridSize, zones: zones, maxShotsInZone: maxCount, totalShots: shots.length);
}
/// Calculate precision statistics
PrecisionStats _calculatePrecision(
List<Shot> shots,
double centerX,
double centerY,
) {
PrecisionStats _calculatePrecision(List<Shot> shots, double centerX, double centerY) {
if (shots.isEmpty) {
return const PrecisionStats(
avgDistanceFromCenter: 0,
groupingDiameter: 0,
precisionScore: 0,
consistencyScore: 0,
);
return const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0);
}
// Calculate distances from center
final distances = shots.map((shot) {
final dx = shot.x - centerX;
final dy = shot.y - centerY;
@@ -297,8 +251,6 @@ class StatisticsService {
}).toList();
final avgDistance = distances.reduce((a, b) => a + b) / distances.length;
// Calculate grouping (spread between shots)
double maxSpread = 0;
for (int i = 0; i < shots.length; i++) {
for (int j = i + 1; j < shots.length; j++) {
@@ -309,45 +261,29 @@ class StatisticsService {
}
}
// Calculate standard deviation of distances (consistency)
final meanDist = avgDistance;
double variance = 0;
for (final d in distances) {
variance += math.pow(d - meanDist, 2);
variance += math.pow(d - avgDistance, 2);
}
final stdDevDist = math.sqrt(variance / distances.length);
// Precision score: based on average distance from center (0-100)
// 0 distance = 100 score, 0.5 distance = 0 score
final precisionScore = math.max(0, (1 - avgDistance * 2) * 100);
// Consistency score: based on grouping tightness (0-100)
// Lower spread = higher consistency
final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100);
return PrecisionStats(
avgDistanceFromCenter: avgDistance.toDouble(),
groupingDiameter: maxSpread.toDouble(),
precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(),
consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(),
avgDistanceFromCenter: avgDistance,
groupingDiameter: maxSpread,
precisionScore: precisionScore.toDouble().clamp(0.0, 100.0),
consistencyScore: consistencyScore.toDouble().clamp(0.0, 100.0),
);
}
/// Calculate standard deviation statistics
StdDevStats _calculateStdDev(List<Shot> shots) {
if (shots.isEmpty) {
return const StdDevStats(
stdDevX: 0,
stdDevY: 0,
stdDevRadial: 0,
stdDevScore: 0,
meanX: 0.5,
meanY: 0.5,
meanScore: 0,
);
return const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0);
}
// Calculate means
double sumX = 0, sumY = 0, sumScore = 0;
for (final shot in shots) {
sumX += shot.x;
@@ -358,7 +294,6 @@ class StatisticsService {
final meanY = sumY / shots.length;
final meanScore = sumScore / shots.length;
// Calculate variances
double varianceX = 0, varianceY = 0, varianceScore = 0;
for (final shot in shots) {
varianceX += math.pow(shot.x - meanX, 2);
@@ -369,18 +304,11 @@ class StatisticsService {
varianceY /= shots.length;
varianceScore /= shots.length;
final stdDevX = math.sqrt(varianceX);
final stdDevY = math.sqrt(varianceY);
final stdDevScore = math.sqrt(varianceScore);
// Radial standard deviation
final stdDevRadial = math.sqrt(varianceX + varianceY);
return StdDevStats(
stdDevX: stdDevX,
stdDevY: stdDevY,
stdDevRadial: stdDevRadial,
stdDevScore: stdDevScore,
stdDevX: math.sqrt(varianceX),
stdDevY: math.sqrt(varianceY),
stdDevRadial: math.sqrt(varianceX + varianceY),
stdDevScore: math.sqrt(varianceScore),
meanX: meanX,
meanY: meanY,
meanScore: meanScore,
@@ -388,42 +316,13 @@ class StatisticsService {
}
/// Calculate regional distribution
RegionalStats _calculateRegional(
List<Shot> shots,
double centerX,
double centerY,
) {
RegionalStats _calculateRegional(List<Shot> shots, double centerX, double centerY) {
if (shots.isEmpty) {
return const RegionalStats(
quadrantDistribution: {},
sectorDistribution: {},
dominantDirection: 'Centre',
biasX: 0,
biasY: 0,
);
return const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0);
}
// Quadrant distribution
final quadrants = <String, int>{
'Haut-Gauche': 0,
'Haut-Droite': 0,
'Bas-Gauche': 0,
'Bas-Droite': 0,
};
// Sector distribution (8 sectors + center)
final sectors = <String, int>{
'N': 0,
'NE': 0,
'E': 0,
'SE': 0,
'S': 0,
'SO': 0,
'O': 0,
'NO': 0,
'Centre': 0,
};
final quadrants = <String, int>{'Haut-Gauche': 0, 'Haut-Droite': 0, 'Bas-Gauche': 0, 'Bas-Droite': 0};
final sectors = <String, int>{'N': 0, 'NE': 0, 'E': 0, 'SE': 0, 'S': 0, 'SO': 0, 'O': 0, 'NO': 0, 'Centre': 0};
double sumDx = 0, sumDy = 0;
for (final shot in shots) {
@@ -432,16 +331,12 @@ class StatisticsService {
sumDx += dx;
sumDy += dy;
// Quadrant
if (dy < 0) {
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] =
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
} else {
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] =
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
}
// Sector
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < 0.1) {
sectors['Centre'] = sectors['Centre']! + 1;
@@ -452,11 +347,6 @@ class StatisticsService {
}
}
// Calculate bias
final biasX = sumDx / shots.length;
final biasY = sumDy / shots.length;
// Find dominant direction
String dominant = 'Centre';
int maxCount = 0;
sectors.forEach((key, value) {
@@ -470,14 +360,12 @@ class StatisticsService {
quadrantDistribution: quadrants,
sectorDistribution: sectors,
dominantDirection: dominant,
biasX: biasX,
biasY: biasY,
biasX: sumDx / shots.length,
biasY: sumDy / shots.length,
);
}
String _angleToSector(double angle) {
// Angle is in degrees, -180 to 180
// 0 = East, 90 = South, -90 = North, 180/-180 = West
if (angle >= -22.5 && angle < 22.5) return 'E';
if (angle >= 22.5 && angle < 67.5) return 'SE';
if (angle >= 67.5 && angle < 112.5) return 'S';
@@ -491,41 +379,12 @@ class StatisticsService {
SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) {
return SessionStatistics(
totalShots: 0,
totalScore: 0,
avgScore: 0,
maxScore: 0,
minScore: 0,
heatMap: const HeatMap(
gridSize: 5,
zones: [],
maxShotsInZone: 0,
totalShots: 0,
),
precision: const PrecisionStats(
avgDistanceFromCenter: 0,
groupingDiameter: 0,
precisionScore: 0,
consistencyScore: 0,
),
stdDev: const StdDevStats(
stdDevX: 0,
stdDevY: 0,
stdDevRadial: 0,
stdDevScore: 0,
meanX: 0.5,
meanY: 0.5,
meanScore: 0,
),
regional: const RegionalStats(
quadrantDistribution: {},
sectorDistribution: {},
dominantDirection: 'Centre',
biasX: 0,
biasY: 0,
),
sessions: sessions,
period: period,
totalShots: 0, totalScore: 0, avgScore: 0, maxScore: 0, minScore: 0,
heatMap: const HeatMap(gridSize: 5, zones: [], maxShotsInZone: 0, totalShots: 0),
precision: const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0),
stdDev: const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0),
regional: const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0),
sessions: sessions, period: period,
);
}
}

View File

@@ -1,377 +0,0 @@
import 'dart:math' as math;
import '../data/models/target_type.dart';
import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart';
export 'image_processing_service.dart'
show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact;
class TargetDetectionResult {
final double centerX; // Relative (0-1)
final double centerY; // Relative (0-1)
final double radius; // Relative (0-1)
final List<DetectedImpactResult> impacts;
final bool success;
final String? errorMessage;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
required this.impacts,
this.success = true,
this.errorMessage,
});
factory TargetDetectionResult.error(String message) {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
impacts: [],
success: false,
errorMessage: message,
);
}
}
class DetectedImpactResult {
final double x; // Relative (0-1)
final double y; // Relative (0-1)
final double radius; // Absolute pixels
final int suggestedScore;
DetectedImpactResult({
required this.x,
required this.y,
required this.radius,
required this.suggestedScore,
});
}
class TargetDetectionService {
final ImageProcessingService _imageProcessingService;
final OpenCVImpactDetectionService _opencvService;
TargetDetectionService({
ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
try {
// Detect main target
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
// Detect impacts
final impacts = _imageProcessingService.detectImpacts(imagePath);
// Convert impacts to relative coordinates and calculate scores
final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection: $e');
}
}
int _calculateConcentricScore(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones (10 zones)
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0; // Outside target
}
int _calculateSilhouetteScore(
double impactX,
double impactY,
double centerX,
double centerY,
) {
// For silhouettes, scoring is typically based on zones
// Head and center mass = 5, body = 4, lower = 3
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
// Check if within silhouette bounds (approximate)
if (dx > 0.15) return 0; // Too far left/right
// Vertical zones
if (dy < -0.25) return 5; // Head zone (top)
if (dy < 0.0) return 5; // Center mass (upper body)
if (dy < 0.15) return 4; // Body
if (dy < 0.35) return 3; // Lower body
return 0; // Outside target
}
/// Detect only impacts with custom settings (doesn't affect target position)
List<DetectedImpactResult> detectImpactsOnly(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactDetectionSettings settings,
) {
try {
// Detect impacts with custom settings
final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath,
settings,
);
// Convert impacts to relative coordinates and calculate scores
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
int _calculateConcentricScoreWithRings(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
int ringCount,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones based on ringCount
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) {
return 10 - i;
}
}
return 0; // Outside target
}
/// Analyze reference impacts to learn their characteristics
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references,
) {
return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
}
/// Detect impacts based on reference characteristics (calibrated detection)
List<DetectedImpactResult> detectImpactsFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactCharacteristics characteristics, {
double tolerance = 2.0,
}) {
try {
final impacts = _imageProcessingService.detectImpactsFromReferences(
imagePath,
characteristics,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
/// Détecte les impacts en utilisant OpenCV (Hough Circles + Contours)
///
/// Cette méthode utilise les algorithmes OpenCV pour une détection plus robuste:
/// - Transformation de Hough pour détecter les cercles
/// - Analyse de contours avec filtrage par circularité
List<DetectedImpactResult> detectImpactsWithOpenCV(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount, {
OpenCVDetectionSettings? settings,
}) {
try {
final impacts = _opencvService.detectImpacts(
imagePath,
settings: settings ?? const OpenCVDetectionSettings(),
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
print('Erreur détection OpenCV: $e');
return [];
}
}
/// Détecte les impacts avec OpenCV en utilisant des références
///
/// Analyse les impacts de référence pour apprendre leurs caractéristiques
/// puis détecte les impacts similaires dans l'image.
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
List<ReferenceImpact> references, {
double tolerance = 2.0,
}) {
try {
// Convertir les références au format OpenCV
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
final impacts = _opencvService.detectFromReferences(
imagePath,
refPoints,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
print('Erreur détection OpenCV depuis références: $e');
return [];
}
}
}

View File

@@ -0,0 +1,113 @@
import 'dart:math';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
class WalletIdentityService {
static const String _prefsKey = 'wallet_identity_phrase';
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
// A standard list of 256 words (8 bits of entropy per word)
static const List<String> _wordList = [
// ... same as before
'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', 'absurd', 'abuse',
'access', 'accident', 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire', 'across', 'act',
'action', 'actor', 'actress', 'actual', 'adapt', 'add', 'addict', 'address', 'adjust', 'admit',
'adult', 'advance', 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent',
'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', 'alcohol', 'alert',
'alien', 'all', 'alley', 'allow', 'almost', 'alone', 'alpha', 'already', 'also', 'alter',
'always', 'amateur', 'amazing', 'among', 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger',
'angle', 'angry', 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique',
'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'arch', 'arctic',
'area', 'arena', 'argue', 'arm', 'armed', 'armor', 'army', 'around', 'arrange', 'arrest',
'arrive', 'arrow', 'art', 'artefact', 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset',
'assist', 'assume', 'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction',
'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake',
'aware', 'away', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon', 'badge',
'bag', 'balance', 'balcony', 'ball', 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain',
'barrel', 'base', 'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become',
'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit',
'best', 'betray', 'better', 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind', 'biology',
'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless',
'blind', 'blood', 'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body',
'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring', 'borrow', 'boss',
'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave', 'bread',
'breeze', 'brick', 'bridge', 'brief', 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze',
'broom', 'brother', 'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb',
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
];
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA
Future<bool> isUploadEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_uploadEnabledKey) ?? false;
}
/// Active ou désactive l'envoi de données
Future<void> setUploadEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_uploadEnabledKey, enabled);
}
/// Gets the unique 15-word identity phrase
Future<String> getIdentityPhrase() async {
final prefs = await SharedPreferences.getInstance();
// Check if we already generated it
final existingPhrase = prefs.getString(_prefsKey);
if (existingPhrase != null && existingPhrase.isNotEmpty) {
return existingPhrase;
}
// Generate new phrase based on device ID
final phrase = await _generatePhraseFromDevice();
// Save for future use
await prefs.setString(_prefsKey, phrase);
return phrase;
}
Future<String> _generatePhraseFromDevice() async {
final deviceInfo = DeviceInfoPlugin();
String deviceId = 'unknown_device_${DateTime.now().millisecondsSinceEpoch}';
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
deviceId = androidInfo.id; // Unique Android ID
} else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
deviceId = iosInfo.identifierForVendor ?? deviceId;
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
deviceId = windowsInfo.deviceId;
}
} catch (e) {
debugPrint('Erreur lecture device ID: $e');
}
// Add a salt to the device ID
final salt = 'bully_app_wallet_salt_v1';
final bytes = utf8.encode(deviceId + salt);
final digest = sha256.convert(bytes);
// Use the hash to seed a random number generator
// We take the first 4 bytes of the hash as the seed
final seedBytes = digest.bytes.sublist(0, 4);
final seed = (seedBytes[0] << 24) | (seedBytes[1] << 16) | (seedBytes[2] << 8) | seedBytes[3];
final random = Random(seed);
List<String> phraseWords = [];
for (int i = 0; i < 15; i++) {
final randomIndex = random.nextInt(_wordList.length);
phraseWords.add(_wordList[randomIndex]);
}
return phraseWords.join(' ');
}
}

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -7,12 +7,12 @@ import Foundation
import device_info_plus
import file_selector_macos
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}

View File

@@ -5,18 +5,26 @@ packages:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.7"
version: "4.0.9"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
@@ -25,6 +33,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
camera:
dependency: "direct main"
description:
name: camera
sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777"
url: "https://pub.dev"
source: hosted
version: "0.12.0+1"
camera_android_camerax:
dependency: transitive
description:
name: camera_android_camerax
sha256: b5064cf25a2787d122d0bf12e77c7b1033a2b983d0730e3091f770ee376efde5
url: "https://pub.dev"
source: hosted
version: "0.7.2"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d"
url: "https://pub.dev"
source: hosted
version: "0.10.1"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
url: "https://pub.dev"
source: hosted
version: "0.3.5+3"
change_case:
dependency: transitive
description:
@@ -74,7 +122,7 @@ packages:
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -85,18 +133,18 @@ packages:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.8"
version: "1.0.9"
dartcv4:
dependency: transitive
description:
name: dartcv4
sha256: "43dba49162662f3b6e3daf5a95d071429365e2f1ada67d412b851fc9be442e58"
sha256: "5764d13550055da3bb35ca28e6866b837579dfb4b594582b7a7753785ca5dfef"
url: "https://pub.dev"
source: hosted
version: "2.2.1+1"
version: "2.2.1+4"
device_info_plus:
dependency: "direct main"
description:
@@ -223,10 +271,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.33"
version: "2.0.34"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -245,24 +293,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_mlkit_document_scanner:
dependency: "direct main"
description:
name: google_mlkit_document_scanner
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.0.3"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -281,26 +321,26 @@ packages:
dependency: "direct main"
description:
name: image
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.7.2"
version: "4.8.0"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
url: "https://pub.dev"
source: hosted
version: "0.8.13+13"
version: "0.8.13+17"
image_picker_for_web:
dependency: transitive
description:
@@ -357,6 +397,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
leak_tracker:
dependency: transitive
description:
@@ -385,10 +441,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
version: "6.1.0"
logging:
dependency: transitive
description:
@@ -433,18 +489,18 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
version: "0.17.6"
native_toolchain_cmake:
dependency: transitive
description:
name: native_toolchain_cmake
sha256: fe40e8483183ced98e851e08a9cd2a547fd412cccab98277aa23f2377e43d66f
sha256: cd4865568c57f03c7ab1f138cc5032c5f77825d27083d6f5a3e9bbb3e8014a87
url: "https://pub.dev"
source: hosted
version: "0.2.4"
version: "0.2.5"
nested:
dependency: transitive
description:
@@ -453,14 +509,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
opencv_dart:
dependency: "direct main"
description:
name: opencv_dart
sha256: c2b7cc614cad69c2857e9b684e3066af662a03fe7100f4dc9a630e81ad42103a
sha256: b50ea4788e6d61df6369874e0244445d98ef9210dbb90b8d3c1df198b4ee2333
url: "https://pub.dev"
source: hosted
version: "2.2.1+1"
version: "2.2.1+4"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
@@ -481,18 +553,18 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.2.22"
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@@ -569,10 +641,10 @@ packages:
dependency: transitive
description:
name: petitparser
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -593,10 +665,10 @@ packages:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
version: "6.5.0"
provider:
dependency: "direct main"
description:
@@ -613,6 +685,86 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
sensors_plus:
dependency: "direct main"
description:
name: sensors_plus
sha256: "8e7fa79b4940442bb595bfc0ee9da4af5a22a0fe6ebacc74998245ee9496a82d"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
sensors_plus_platform_interface:
dependency: transitive
description:
name: sensors_plus_platform_interface
sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f
url: "https://pub.dev"
source: hosted
version: "1.2.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -622,42 +774,42 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
version: "1.10.2"
sqflite:
dependency: "direct main"
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.2+1"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+2"
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
sha256: f8a08a13fb8f0f8c590df89d745000bed44a673ed94bac846739e1a016875c21
url: "https://pub.dev"
source: hosted
version: "2.5.6"
version: "2.5.7"
sqflite_common_ffi:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff
sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20
url: "https://pub.dev"
source: hosted
version: "2.4.0+2"
version: "2.4.0+3"
sqflite_darwin:
dependency: transitive
description:
@@ -678,10 +830,10 @@ packages:
dependency: transitive
description:
name: sqlite3
sha256: "00e5e65f8e9b556ed3d999ad310881c956ffb656ed96bea487a4c50ffdff6d14"
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
version: "3.3.1"
stack_trace:
dependency: transitive
description:
@@ -698,6 +850,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -742,10 +902,10 @@ packages:
dependency: "direct main"
description:
name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -758,10 +918,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
version: "15.2.0"
web:
dependency: transitive
description:
@@ -774,10 +934,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: ba7d5750e3441caa1bbe31d9e516348fcf8dfcb32aa29ef87a844a59f4d1f1d0
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "6.2.0"
win32_registry:
dependency: transitive
description:
@@ -811,5 +971,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1"
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"

View File

@@ -1,5 +1,5 @@
name: bully
description: "A new Flutter project."
description: "Application d'analyse de cibles de tir : capture, centrage, plotting manuel des impacts, scores et statistiques."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -36,12 +36,11 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
sensors_plus: ^4.0.2
opencv_dart: ^2.1.0
# Image capture from camera/gallery
image_picker: ^1.2.1
google_mlkit_document_scanner: ^0.2.0
# Local database for history
sqflite: ^2.3.2
@@ -66,10 +65,11 @@ dependencies:
# Image processing for impact detection
image: ^4.1.7
permission_handler: ^12.0.1
http: ^1.6.0
device_info_plus: ^13.1.0
# Machine Learning for YOLOv8
# tflite_flutter: ^0.11.0
shared_preferences: ^2.5.5
crypto: ^3.0.7
camera: ^0.12.0+1
dev_dependencies:
flutter_test:
@@ -95,7 +95,6 @@ flutter:
# To add assets to your application, add an assets section, like this:
# assets:
# - assets/models/yolov8n_32.tflite
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg

View File

@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/data/models/shot.dart';
import 'package:bully/services/grouping_analyzer_service.dart';
Shot _shot(String id, double x, double y) =>
Shot(id: id, x: x, y: y, score: 0, analysisId: '');
void main() {
final service = GroupingAnalyzerService();
group('analyzeGrouping', () {
test('liste vide → résultat vide', () {
final result = service.analyzeGrouping([]);
expect(result.shotCount, 0);
expect(result.diameter, 0);
});
test('un seul tir → diamètre nul, centre sur le tir', () {
final result = service.analyzeGrouping([_shot('a', 0.3, 0.7)]);
expect(result.shotCount, 1);
expect(result.centerX, 0.3);
expect(result.centerY, 0.7);
expect(result.diameter, 0);
});
test('deux tirs → centroïde au milieu et diamètre = écart', () {
final result = service.analyzeGrouping([
_shot('a', 0.4, 0.5),
_shot('b', 0.6, 0.5),
]);
expect(result.centerX, closeTo(0.5, 1e-9));
expect(result.centerY, closeTo(0.5, 1e-9));
expect(result.diameter, closeTo(0.2, 1e-9));
expect(result.meanRadius, closeTo(0.1, 1e-9));
// Deux tirs équidistants du centroïde → dispersion nulle.
expect(result.standardDeviation, closeTo(0.0, 1e-9));
});
test('le diamètre est la plus grande distance entre deux tirs', () {
final result = service.analyzeGrouping([
_shot('a', 0.2, 0.5),
_shot('b', 0.5, 0.5),
_shot('c', 0.8, 0.5),
]);
expect(result.diameter, closeTo(0.6, 1e-9));
});
});
group('getOffsetDescription', () {
test('décalage négligeable → Centre', () {
expect(service.getOffsetDescription(0.01, -0.01), 'Centre');
});
test('décalage combiné → direction composée', () {
expect(service.getOffsetDescription(0.1, -0.1), 'Haut-Droite');
});
});
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/data/models/shot.dart';
import 'package:bully/data/models/target_type.dart';
import 'package:bully/services/score_calculator_service.dart';
void main() {
final service = ScoreCalculatorService();
group('calculateConcentricScore', () {
test('un tir au centre exact vaut 10', () {
final score = service.calculateConcentricScore(
shotX: 0.5,
shotY: 0.5,
targetCenterX: 0.5,
targetCenterY: 0.5,
targetRadius: 0.4,
);
expect(score, 10);
});
test('un tir hors de la cible vaut 0', () {
final score = service.calculateConcentricScore(
shotX: 0.95,
shotY: 0.5,
targetCenterX: 0.5,
targetCenterY: 0.5,
targetRadius: 0.4,
);
expect(score, 0);
});
test('un tir à mi-rayon tombe dans la zone attendue', () {
// Distance normalisée ≈ 0.475 → 5e anneau (zone 0.5) → score 6.
final score = service.calculateConcentricScore(
shotX: 0.5,
shotY: 0.69,
targetCenterX: 0.5,
targetCenterY: 0.5,
targetRadius: 0.4,
);
expect(score, 6);
});
test('le ratio d\'image est pris en compte (image paysage 2:1)', () {
// dx = 0.15 mais scaled ×2 → distance normalisée 0.75 → score 3.
final score = service.calculateConcentricScore(
shotX: 0.65,
shotY: 0.5,
targetCenterX: 0.5,
targetCenterY: 0.5,
targetRadius: 0.4,
imageAspectRatio: 2.0,
);
expect(score, 3);
});
test('les rayons d\'anneaux personnalisés sont respectés', () {
// Distance normalisée 0.3 avec anneaux [0.2, 0.5, 1.0] → 2e anneau → 9.
final score = service.calculateConcentricScore(
shotX: 0.5,
shotY: 0.62,
targetCenterX: 0.5,
targetCenterY: 0.5,
targetRadius: 0.4,
ringCount: 3,
ringRadii: [0.2, 0.5, 1.0],
);
expect(score, 9);
});
});
group('calculateScores', () {
test('agrège le total, le max possible et le pourcentage', () {
final shots = [
Shot(id: 'a', x: 0.5, y: 0.5, score: 0, analysisId: ''), // centre → 10
Shot(id: 'b', x: 0.95, y: 0.5, score: 0, analysisId: ''), // dehors → 0
];
final result = service.calculateScores(
shots: shots,
targetType: TargetType.concentric,
targetCenterX: 0.5,
targetCenterY: 0.5,
targetRadius: 0.4,
);
expect(result.totalScore, 10);
expect(result.maxPossibleScore, 20);
expect(result.percentage, closeTo(50.0, 0.001));
expect(result.shotCount, 2);
expect(result.scoreDistribution[10], 1);
expect(result.scoreDistribution[0], 1);
});
test('une liste vide donne un résultat neutre', () {
final result = service.calculateScores(
shots: [],
targetType: TargetType.concentric,
targetCenterX: 0.5,
targetCenterY: 0.5,
targetRadius: 0.4,
);
expect(result.totalScore, 0);
expect(result.maxPossibleScore, 0);
expect(result.percentage, 0.0);
});
});
}

View File

@@ -1,20 +1,32 @@
// This is a basic Flutter widget test.
// Test de fumée sur un widget pur (sans providers ni base de données).
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
// Le montage complet de BullyApp exige les providers globaux et une base
// SQLite initialisée ; pour un test widget rapide et stable, on valide ici
// le rendu d'un composant autonome.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/app.dart';
import 'package:bully/features/home/widgets/stats_card.dart';
void main() {
testWidgets('App loads correctly', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const BullyApp());
testWidgets('StatsCard affiche le titre, la valeur et l\'icône',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: StatsCard(
icon: Icons.gps_fixed,
title: 'Tirs',
value: '42',
color: Colors.blue,
),
),
),
);
// Verify that the app title is displayed.
expect(find.text('Bully'), findsOneWidget);
expect(find.text('Tirs'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.byIcon(Icons.gps_fixed), findsOneWidget);
});
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)