18 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
79 changed files with 2535 additions and 5588 deletions

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

Binary file not shown.

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

View File

@@ -37,10 +37,6 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- ML Kit Document Scanner : télécharge le module de scan de la cible -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="docscanner" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -13,8 +13,7 @@ dashboard/out/
.env.test.local
.env.production.local
# Database
backendia/uploads/data/database.sqlite
# Database (contient des données utilisateurs — jamais dans git)
*.sqlite
*.sqlite-journal
@@ -23,7 +22,6 @@ uploads/images/*
!uploads/images/.gitkeep
uploads/data/*
!uploads/data/.gitkeep
!uploads/data/database.sqlite
exports/*
!exports/.gitkeep

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

View File

View File

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

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart';
import 'core/theme/app_theme.dart';
import 'main_navigation_holder.dart';
import 'features/home/home_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class BullyApp extends StatelessWidget {

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,4 +1,3 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/session.dart';
@@ -11,6 +10,12 @@ 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();
@@ -20,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!;
}
@@ -373,17 +380,71 @@ class DatabaseHelper {
limit: limit,
offset: offset,
);
if (sessionMaps.isEmpty) return [];
final sessions = <Session>[];
for (final sessionMap in sessionMaps) {
final sessionId = sessionMap['id'] as String;
final session = await getSession(sessionId);
if (session != null) {
sessions.add(session);
}
// 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));
}
return sessions;
final analysesBySession = <String, List<TargetAnalysis>>{};
for (final map in analysisMaps) {
final analysis = TargetAnalysis.fromMap(
map,
shotsByAnalysis[map['id'] as String] ?? [],
);
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
}
return sessionMaps
.map((m) =>
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
.toList();
}
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
Future<List<Map<String, Object?>>> _queryIn(
Database db,
String table,
String column,
List<String> values,
) async {
if (values.isEmpty) return [];
const chunkSize = 500;
final results = <Map<String, Object?>>[];
for (var i = 0; i < values.length; i += chunkSize) {
final chunk = values.sublist(
i,
i + chunkSize > values.length ? values.length : i + chunkSize,
);
final placeholders = List.filled(chunk.length, '?').join(',');
results.addAll(await db.query(
table,
where: '$column IN ($placeholders)',
whereArgs: chunk,
));
}
return results;
}
Future<int> deleteSession(String id) async {
@@ -510,6 +571,16 @@ class DatabaseHelper {
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(
@@ -543,5 +614,6 @@ class DatabaseHelper {
final db = await database;
await db.close();
_database = null;
_initFuture = null;
}
}

View File

@@ -182,18 +182,23 @@ class SessionRepository {
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: DateTime.now(),
date: date ?? DateTime.now(),
roundsSinceLastMaintenance: roundsSinceLast,
);
await _databaseHelper.insertMaintenance(entry);

View File

@@ -1,8 +1,8 @@
/// 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';
@@ -13,37 +13,25 @@ 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,
}) : _scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository,
_distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
_sessionRepository = sessionRepository;
AnalysisState _state = AnalysisState.initial;
String? _errorMessage;
@@ -53,7 +41,7 @@ class AnalysisProvider extends ChangeNotifier {
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
double _cropRotation = 0.0;
// Target detection results
// Target calibration
double _targetCenterX = 0.5;
double _targetCenterY = 0.5;
double _targetRadius = 0.4;
@@ -71,15 +59,6 @@ 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;
@@ -100,21 +79,6 @@ 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;
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath
: _imagePath;
/// Modifie et mémorise la rotation de l'image pour le Plotting
void setCropRotation(double rotation) {
@@ -122,16 +86,13 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// 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 {
String imagePath,
TargetType targetType, {
Offset? manualCenter,
}) async {
_state = AnalysisState.loading;
_imagePath = imagePath;
_targetType = targetType;
@@ -147,54 +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 = manualCenter?.dx ?? 0.5;
_targetCenterY = manualCenter?.dy ?? 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;
}
final result = await _detectionService.detectTargetAsync(
imagePath,
targetType,
);
if (!result.success) {
_state = AnalysisState.error;
_errorMessage = result.errorMessage;
notifyListeners();
return;
}
_targetCenterX = result.centerX;
_targetCenterY = result.centerY;
_targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts
_shots = result.impacts.map((impact) {
return Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
analysisId: '',
);
}).toList();
// Calculate scores
_recalculateScores();
// Calculate grouping
_recalculateGrouping();
_shots = [];
_state = AnalysisState.success;
notifyListeners();
@@ -216,22 +135,18 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Remove a shot
void removeShot(String shotId) {
_shots.removeWhere((shot) => shot.id == shotId);
/// 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();
}
/// 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);
/// Remove a shot
void removeShot(String shotId) {
_shots.removeWhere((shot) => shot.id == shotId);
_recalculateScores();
_recalculateGrouping();
notifyListeners();
@@ -247,276 +162,17 @@ 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,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
Future<int> autoDetectImpactsWithOpenCV({
double cannyThreshold1 = 50,
double cannyThreshold2 = 150,
double minDist = 20,
double param1 = 100,
double param2 = 30,
int minRadius = 5,
int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5,
bool useContourDetection = true,
double minCircularity = 0.6,
double minContourArea = 50,
double maxContourArea = 5000,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = OpenCVDetectionSettings(
cannyThreshold1: cannyThreshold1,
cannyThreshold2: cannyThreshold2,
minDist: minDist,
param1: param1,
param2: param2,
minRadius: minRadius,
maxRadius: maxRadius,
blurSize: blurSize,
useContourDetection: useContourDetection,
minCircularity: minCircularity,
minContourArea: minContourArea,
maxContourArea: maxContourArea,
);
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings: settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Detect impacts with OpenCV using reference points
Future<int> detectFromReferencesWithOpenCV({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_referenceImpacts.length < 2) {
return 0;
}
// Convertir les références
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
references,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
_referenceImpacts.add(shot);
notifyListeners();
}
/// Remove a reference impact
void removeReferenceImpact(String shotId) {
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
_learnedCharacteristics = null;
notifyListeners();
}
/// Clear all reference impacts
void clearReferenceImpacts() {
_referenceImpacts.clear();
_learnedCharacteristics = null;
notifyListeners();
}
/// Learn characteristics from reference impacts
bool learnFromReferences() {
if (_imagePath == null || _referenceImpacts.length < 2) return false;
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
_imagePath!,
references,
);
notifyListeners();
return _learnedCharacteristics != null;
}
/// Auto-detect impacts using learned reference characteristics
Future<int> detectFromReferences({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_learnedCharacteristics == null) {
return 0;
}
final detectedImpacts = _detectionService.detectImpactsFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
_learnedCharacteristics!,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Adjust target position
void adjustTargetPosition(
double centerX,
double centerY,
double innerRadius,
double radius, {
int? ringCount,
List<double>? ringRadii,
double zoomScale = 1.0,
Offset offset = Offset.zero,
}) {
double centerX,
double centerY,
double innerRadius,
double radius, {
int? ringCount,
List<double>? ringRadii,
double zoomScale = 1.0,
Offset offset = Offset.zero,
}) {
_targetCenterX = (centerX - offset.dx) / zoomScale;
_targetCenterY = (centerY - offset.dy) / zoomScale;
_targetRadius = radius / zoomScale;
@@ -539,118 +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;
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
debugPrint('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
notifyListeners();
}
/// Applique la correction de distorsion à l'image
/// Crée une nouvelle image corrigée et la sauvegarde
Future<void> applyDistortionCorrection() async {
if (_imagePath == null || _distortionParams == null) return;
try {
_correctedImagePath = await _distortionService.applyCorrection(
_imagePath!,
_distortionParams!,
);
_distortionCorrectionEnabled = true;
notifyListeners();
} catch (e) {
_errorMessage = 'Erreur lors de la correction: $e';
notifyListeners();
}
}
/// Active ou désactive l'affichage de l'image corrigée
void setDistortionCorrectionEnabled(bool enabled) {
if (enabled && _correctedImagePath == null && _distortionParams != null) {
// Si on active mais pas encore d'image corrigée, la créer
applyDistortionCorrection();
} else {
_distortionCorrectionEnabled = enabled;
notifyListeners();
}
}
/// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel)
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
// 2. Vérification si une correction est réellement nécessaire
if (_distortionParams != null && _distortionParams!.needsCorrection) {
// 3. Application immédiate de la transformation (méthode asynchrone)
await applyDistortionCorrection();
} else {
notifyListeners();
}
}
Future<void> runFullDistortionWorkflow() async {
_state = AnalysisState.loading;
notifyListeners();
try {
calculateDistortion();
await applyDistortionCorrection();
_distortionCorrectionEnabled = true;
_state = AnalysisState.success;
} catch (e) {
_errorMessage = "Erreur de rendu : $e";
_state = AnalysisState.error;
} finally {
notifyListeners();
}
}
int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) {
return _scoreCalculatorService.calculateConcentricScore(
@@ -694,8 +238,14 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
}
/// Exporte l'image et le json vers le backend IA
Future<bool> exportToAiBackend() async {
/// 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();
@@ -709,12 +259,14 @@ class AnalysisProvider extends ChangeNotifier {
final success = await service.exportData(
imagePath: _imagePath!,
sessionId: 'export',
sessionId: sessionId ?? 'export',
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
shots: _shots,
distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown',
);
_state = AnalysisState.success;
@@ -807,11 +359,6 @@ class AnalysisProvider extends ChangeNotifier {
_shots = [];
_scoreResult = null;
_groupingResult = null;
_referenceImpacts = [];
_learnedCharacteristics = null;
_distortionCorrectionEnabled = false;
_distortionParams = null;
_correctedImagePath = null;
notifyListeners();
}

View File

@@ -12,11 +12,10 @@ import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../../data/models/shot.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/wallet_identity_service.dart';
import '../session/session_provider.dart';
import 'analysis_provider.dart';
import 'impact_editor_screen.dart';
@@ -26,6 +25,7 @@ import 'widgets/target_overlay.dart';
import 'widgets/target_calibration.dart';
import 'widgets/score_card.dart';
import 'widgets/grouping_stats.dart';
import 'widgets/shot_details_sheet.dart';
class AnalysisScreen extends StatelessWidget {
final String imagePath;
@@ -60,7 +60,6 @@ class AnalysisScreen extends StatelessWidget {
return ChangeNotifierProvider(
create: (context) {
final p = AnalysisProvider(
detectionService: context.read<TargetDetectionService>(),
scoreCalculatorService: context.read<ScoreCalculatorService>(),
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
sessionRepository: context.read<SessionRepository>(),
@@ -72,7 +71,6 @@ class AnalysisScreen extends StatelessWidget {
p.analyzeImage(
imagePath,
targetType,
autoAnalyze: false,
manualCenter: manualCenterOffset,
);
return p;
@@ -110,7 +108,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
// Forcé à TRUE pour démarrer sur l'ajustement des cercles
bool _isCalibrating = true;
bool _isSelectingReferences = false;
bool _isAtBottom = false;
final ScrollController _scrollController = ScrollController();
@@ -118,7 +115,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
String? _movingShotId;
@override
void initState() {
@@ -192,7 +188,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
if (validated == true) {
setState(() {
_isCalibrating = false;
_isSelectingReferences = false;
});
} else {
_enterCalibration();
@@ -250,13 +245,66 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
},
),
actions: [
if (!_isCalibrating && !_isSelectingReferences)
// Remise à zéro des impacts : efface tous les impacts en un clic,
// sans modifier la calibration (centre, rayon, anneaux).
if (!_isCalibrating)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => provider.analyzeImage(
context.read<AnalysisProvider>().imagePath!,
context.read<AnalysisProvider>().targetType!,
),
tooltip: 'Effacer tous les impacts',
onPressed: () => provider.clearShots(),
),
// Nuage d'export vers le backend IA : visible uniquement si l'analyse
// a réussi ET que l'utilisateur a activé l'option dans les Paramètres.
if (!_isCalibrating)
FutureBuilder<bool>(
future: WalletIdentityService().isUploadEnabled(),
builder: (context, snapshot) {
final isEnabled = snapshot.data ?? false;
if (!isEnabled ||
provider.state != AnalysisState.success) {
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.cloud_upload),
tooltip: 'Exporter pour IA',
onPressed: () async {
final p = context.read<AnalysisProvider>();
if (p.state != AnalysisState.success) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Exportation en cours...')),
);
// Métadonnées réelles de la session en cours (distance,
// arme) plutôt que les placeholders par défaut.
final sp = context.read<SessionProvider>();
final success = await p.exportToAiBackend(
sessionId: sp.activeSessionId,
distance: sp.distance,
weapon: sp.currentWeapon,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Export réussi vers le backend IA !'),
backgroundColor: AppTheme.successColor,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(p.errorMessage ?? 'Erreur d\'export'),
backgroundColor: AppTheme.errorColor,
),
);
}
},
);
},
),
if (_isCalibrating)
TextButton(
@@ -269,7 +317,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
_openImpactEditor(context.read<AnalysisProvider>());
},
child: const Text(
'TERMINER',
'VALIDER',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
@@ -316,7 +364,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
widget.cropOffset?.dy ?? 0.0,
0.0,
)
..scale(1.0, 1.0)
..rotateZ(
(provider.cropRotation) *
(math.pi / 180),
@@ -424,8 +471,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
),
const SizedBox(height: 12),
_buildActionButtons(context, provider),
const SizedBox(height: 50),
],
),
@@ -593,7 +638,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
zoomScale: _currentZoomScale,
// Lecture seule : tap sur impact -> détails (consultation).
onShotTapped: (shot) =>
_showShotDetails(context, provider, shot),
showShotDetailsSheet(context, provider, shot),
),
),
],
@@ -601,84 +646,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
);
}
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
return const Column(children: [Row(children: [])]);
}
void _showShotDetails(
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Impact #${provider.shots.indexOf(shot) + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
'ID: ${shot.id}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.score),
title: const Text('Modifier le score'),
trailing: DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (index) => index)
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(
'$s',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
)
.toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shot.id, newScore);
Navigator.pop(context);
}
},
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
provider.removeShot(shot.id);
Navigator.pop(context);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text(
'SUPPRIMER',
style: TextStyle(color: Colors.red),
),
),
),
],
),
],
),
),
);
}
void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) {
showDialog(
context: context,

View File

@@ -20,10 +20,10 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/theme/app_theme.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});
@@ -145,7 +145,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
final hit = _hitTestShot(provider, rel);
if (hit != null) {
_showShotDetails(context, provider, hit);
showShotDetailsSheet(context, provider, hit);
} else {
provider.addShot(rel.dx, rel.dy);
}
@@ -193,7 +193,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
// L'ajout et la sélection sont gérés par le
// GestureDetector parent ci-dessus.
onShotTapped: (shot) =>
_showShotDetails(context, provider, shot),
showShotDetailsSheet(context, provider, shot),
),
),
],
@@ -206,79 +206,4 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
),
);
}
void _showShotDetails(
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Impact #${provider.shots.indexOf(shot) + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
'ID: ${shot.id}',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey, fontSize: 10),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.score),
title: const Text('Modifier le score'),
trailing: DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (index) => index)
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(
'$s',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
)
.toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shot.id, newScore);
Navigator.pop(context);
}
},
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
provider.removeShot(shot.id);
Navigator.pop(context);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text(
'SUPPRIMER',
style: TextStyle(color: Colors.red),
),
),
),
],
),
],
),
),
);
}
}

View File

@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart';
import '../../../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';
@@ -131,7 +131,14 @@ class TargetCalibrationState extends State<TargetCalibration> {
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii && widget.initialRingRadii != null) {
// On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
// réellement d'une nouvelle détection (ils diffèrent de notre état courant).
// Sinon il s'agit de l'écho de notre propre _notifyChange (aller-retour via
// le provider) : le clobber effacerait le profil d'origine et casserait le
// bouton de réinitialisation de l'espacement.
if (widget.initialRingRadii != oldWidget.initialRingRadii &&
widget.initialRingRadii != null &&
!listEquals(widget.initialRingRadii, _ringRadii)) {
_originalRingRadii = List.from(widget.initialRingRadii!);
shouldReinit = true;
}
@@ -338,24 +345,41 @@ class TargetCalibrationState extends State<TargetCalibration> {
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Column(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
Row(
_buildSignLabel(''),
const SizedBox(width: 12),
Column(
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_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)),
],
),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
const SizedBox(width: 12),
_buildSignLabel('+'),
],
),
);
}
// Symbole purement décoratif affiché de part et d'autre de la croix.
Widget _buildSignLabel(String text) {
return Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
);
}
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),

View File

@@ -1,9 +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 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.
/// 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';
@@ -20,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;
@@ -38,11 +36,9 @@ 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,
});
@@ -72,7 +68,6 @@ class TargetOverlay extends StatelessWidget {
groupingCenterX: groupingCenterX,
groupingCenterY: groupingCenterY,
groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts,
zoomScale: zoomScale,
showRings: showRings,
),
@@ -127,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;
@@ -142,7 +136,6 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingCenterX,
this.groupingCenterY,
this.groupingDiameter,
this.referenceImpacts,
this.zoomScale = 1.0,
this.showRings = false,
});
@@ -163,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) {
@@ -319,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 ||
@@ -372,7 +316,6 @@ 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

@@ -101,24 +101,9 @@ class _CropScreenState extends State<CropScreen> {
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
children: [
// Zone interactive de crop
Expanded(
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
),
),
),
// TEXTE D'AIDE AJUSTÉ
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Column(
children: [
Row(
@@ -126,21 +111,53 @@ class _CropScreenState extends State<CropScreen> {
children: [
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
const SizedBox(width: 8),
Text(
'Alignez et pivotez la cible sur la croix',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
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.withOpacity(0.5), fontSize: 12),
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
),
],
),
),
// Zone interactive de crop, CARRÉE. La photo la remplit entièrement
// (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre
// est récupérable en déplaçant/zoomant. La sortie d'analyse reste
// carrée, donc la cible n'est pas déformée.
Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
),
),
),
),
),
),
const SizedBox(height: 8),
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel.
_buildDirectionalPad(),
const SizedBox(height: 16),
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
@@ -238,21 +255,9 @@ class _CropScreenState extends State<CropScreen> {
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// FIX : On calcule d'abord la taille de la photo affichée avant de définir la taille du cadre vert !
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// On passe de 0.95 à 0.85 pour matcher parfaitement avec l'appareil photo !
_cropSize = math.min(displayWidth, displayHeight)* 0.85;
// 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();
@@ -269,12 +274,12 @@ class _CropScreenState extends State<CropScreen> {
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale)
..scaleByDouble(_scale, _scale, _scale, 1.0)
..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
fit: BoxFit.contain,
fit: BoxFit.cover,
width: _viewportSize.width,
height: _viewportSize.height,
),
@@ -291,14 +296,14 @@ class _CropScreenState extends State<CropScreen> {
child: Container(
width: double.infinity,
height: 1.5,
color: const Color(0xFF00FF00).withOpacity(0.6),
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withOpacity(0.6),
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
],
@@ -345,21 +350,6 @@ class _CropScreenState extends State<CropScreen> {
void _initializeImagePosition() {
if (_imageSize == null) return;
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
// 1. Calcul strict de la taille de l'image affichée en BoxFit.contain
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final minDisplayDim = math.min(displayWidth, displayHeight);
// 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;
@@ -372,6 +362,61 @@ class _CropScreenState extends State<CropScreen> {
}
}
// 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;
@@ -389,14 +434,13 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
// Facteur d'échelle affichage/source (BoxFit.contain) — identique à
// celui utilisé pour afficher l'image dans l'aperçu.
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
final double displayWidth = imageAspect > viewportAspect
? _viewportSize.width
: _viewportSize.height * imageAspect;
final double displayPerSourcePx = displayWidth / _imageSize!.width;
// 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

View File

@@ -19,6 +19,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon;
List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0;
int _sessionCount = 0;
bool _isLoading = true;
@override
@@ -32,10 +33,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
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;
});
}
@@ -49,7 +52,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditWeaponDialog(context),
onPressed: _showEditWeaponDialog,
),
],
),
@@ -71,7 +74,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddMaintenanceDialog(context),
onPressed: _showAddMaintenanceDialog,
label: const Text('Entretien'),
icon: const Icon(Icons.build),
),
@@ -93,7 +96,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
Expanded(
child: _buildStatCard(
'Sessions',
'N/A',
_sessionCount.toString(),
Icons.history,
Colors.orange,
),
@@ -149,7 +152,24 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
IconButton(
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
),
onPressed: _showEditAccessoriesDialog,
),
],
),
const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
@@ -203,7 +223,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
title: Text(entry.type.displayName),
subtitle: Text(entry.description),
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
onLongPress: () => _confirmDeleteMaintenance(context, entry),
onLongPress: () => _confirmDeleteMaintenance(entry),
),
);
},
@@ -232,7 +252,36 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
}
}
void _showEditWeaponDialog(BuildContext context) async {
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
// (ex: Modérateur) se retrouve caché sous le clavier.
Widget _autoScrollOnFocus(Widget child) {
return Builder(
builder: (context) => Focus(
canRequestFocus: false,
skipTraversal: true,
onFocusChange: (hasFocus) {
if (!hasFocus) return;
// On attend que le clavier ait fini de redimensionner la vue.
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
});
},
child: child,
),
);
}
void _showEditWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber);
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
@@ -253,41 +302,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
_autoScrollOnFocus(TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
),
TextField(
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
),
)),
DropdownButtonFormField<WeaponType>(
value: selectedType,
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(
_autoScrollOnFocus(TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'),
),
)),
const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
TextField(
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
),
TextField(
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
),
TextField(
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
),
)),
const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
Row(
_autoScrollOnFocus(Row(
children: [
Expanded(
child: TextField(
@@ -305,12 +354,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
),
),
],
),
TextField(
)),
_autoScrollOnFocus(TextField(
controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2,
),
)),
],
),
),
@@ -336,17 +385,125 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
customName: customNameController.text.isEmpty ? null : customNameController.text,
);
final repository = context.read<SessionRepository>();
await repository.updateWeapon(updatedWeapon);
if (!mounted) return;
setState(() {
_weapon = updatedWeapon;
});
}
}
void _showAddMaintenanceDialog(BuildContext context) async {
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
// pouvoir comparer les scores selon la configuration utilisée.
void _showEditAccessoriesDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier les accessoires'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
'pour pouvoir comparer les scores selon la configuration.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(
labelText: 'Surnom de la configuration',
hintText: 'ex: Echelon + Holosun',
),
)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
],
),
);
if (result != true || !mounted) return;
final newOptic = opticController.text.isEmpty ? null : opticController.text;
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
// Rien à faire si aucun accessoire n'a changé.
final unchanged = newOptic == _weapon.optic &&
newSilencer == _weapon.silencer &&
newTrigger == _weapon.trigger;
if (unchanged) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
);
return;
}
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
if (newCustomName == null) {
final accessories = [newOptic, newSilencer, newTrigger]
.where((a) => a != null && a.isNotEmpty)
.join(' / ');
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
}
final newWeapon = await repository.addWeapon(
name: _weapon.name,
type: _weapon.type,
caliber: _weapon.caliber,
magazineCount: _weapon.magazineCount,
magazineCapacity: _weapon.magazineCapacity,
notes: _weapon.notes,
optic: newOptic,
silencer: newSilencer,
trigger: newTrigger,
customName: newCustomName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
);
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
);
}
void _showAddMaintenanceDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning;
DateTime selectedDate = DateTime.now();
final result = await showDialog<bool>(
context: context,
@@ -357,7 +514,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<MaintenanceType>(
value: selectedType,
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!),
@@ -367,6 +524,27 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
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: [
@@ -378,18 +556,21 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
);
if (result == true && descController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addMaintenanceEntry(
weaponId: _weapon.id,
type: selectedType,
description: descController.text,
roundsSinceLast: _totalRounds,
date: selectedDate,
);
if (!mounted) return;
_loadData();
}
}
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async {
void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -406,8 +587,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
);
if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteMaintenanceEntry(entry.id);
if (!mounted) return;
_loadData();
}
}

View File

@@ -46,7 +46,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
? _buildEmptyState()
: _buildWeaponList(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddWeaponDialog(context),
onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add),
),
);
@@ -62,7 +62,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
const Text('Aucune arme enregistrée'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => _showAddWeaponDialog(context),
onPressed: _showAddWeaponDialog,
child: const Text('Ajouter ma première arme'),
),
],
@@ -76,26 +76,11 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
itemCount: _weapons.length,
itemBuilder: (context, index) {
final weapon = _weapons[index];
final accessories = _accessoryChips(weapon);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${weapon.type.displayName}${weapon.caliber}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
await Navigator.push(
context,
@@ -103,14 +88,100 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
);
_loadWeapons(); // Reload in case it was edited or maintenance was added
},
onLongPress: () => _confirmDelete(context, weapon),
onLongPress: () => _confirmDelete(weapon),
child: Padding(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text(
'${weapon.type.displayName}${weapon.caliber}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
],
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
),
);
},
);
}
void _showAddWeaponDialog(BuildContext context) async {
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!));
}
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
items.add((Icons.volume_off, weapon.silencer!));
}
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
items.add((Icons.touch_app, weapon.trigger!));
}
return items.map((item) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
const SizedBox(width: 4),
Text(item.$2, style: const TextStyle(fontSize: 12)),
],
),
);
}).toList();
}
void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController();
final caliberController = TextEditingController();
final magCountController = TextEditingController(text: '2');
@@ -131,7 +202,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
),
DropdownButtonFormField<WeaponType>(
value: selectedType,
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!),
@@ -171,7 +242,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
);
if (result == true && nameController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addWeapon(
name: nameController.text,
type: selectedType,
@@ -179,11 +249,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
);
if (!mounted) return;
_loadWeapons();
}
}
void _confirmDelete(BuildContext context, Weapon weapon) async {
void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -200,8 +273,8 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
);
if (confirmed == true) {
final repository = context.read<SessionRepository>();
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,6 +37,16 @@ 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);
@@ -43,6 +59,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
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) {
@@ -103,7 +127,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogBackgroundColor: Colors.white,
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
@@ -125,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

@@ -25,12 +25,43 @@ class _HomeScreenState extends State<HomeScreen> {
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();

View File

@@ -1,7 +1,5 @@
import 'package:flutter/foundation.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/weapon.dart';
class SessionProvider extends ChangeNotifier {
DateTime? _sessionDate;

View File

@@ -168,7 +168,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
Column(
children: [
DropdownButtonFormField<Weapon>(
value: _selectedWeapon,
initialValue: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield),

View File

@@ -67,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) setState(() => _isLoadingStats = false);
}
} catch (e) {
print('Erreur lors du chargement des statistiques: $e');
debugPrint('Erreur lors du chargement des statistiques: $e');
if (mounted) setState(() => _isLoadingStats = false);
}
}
@@ -85,8 +85,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _showThemeDialog() {
final themeProvider = context.read<ThemeProvider>();
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -247,6 +245,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
// Rapport de bug autonome : on collecte une description + les infos
// techniques, et on copie un rapport prêt à coller dans un email de support.
void _showReportBugDialog() {
final descController = TextEditingController();
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
const appVersion = '1.0.0';
final platform = Theme.of(context).platform.name;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler un bug'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Décrivez le problème : ce que vous faisiez, ce qui était attendu et ce qui s\'est passé.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
TextField(
controller: descController,
maxLines: 5,
decoration: const InputDecoration(
hintText: 'Ex: l\'application se ferme quand j\'ouvre une session...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
'Infos techniques jointes : version $appVersion$platform',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier le rapport'),
onPressed: () {
final desc = descController.text.trim();
final report = StringBuffer()
..writeln('--- Rapport de bug ---')
..writeln('Version : $appVersion')
..writeln('Plateforme : $platform')
..writeln('Date : ${DateTime.now().toIso8601String()}')
..writeln('')
..writeln('Description :')
..writeln(desc.isEmpty ? '(non renseignée)' : desc);
Clipboard.setData(ClipboardData(text: report.toString()));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Rapport copié — collez-le dans un email à $supportEmail'),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 5),
),
);
},
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -303,7 +374,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled,
activeColor: AppTheme.primaryColor,
activeThumbColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
@@ -347,6 +418,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
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,

View File

@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
import '../../core/widgets/metric_info_button.dart';
import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/statistics_service.dart';
@@ -10,7 +11,12 @@ import '../../services/statistics_service.dart';
class StatisticsScreen extends StatefulWidget {
final Session? singleSession;
const StatisticsScreen({super.key, this.singleSession});
/// Incrémenté par la navigation à chaque fois que l'onglet Stats est ouvert,
/// pour forcer un rechargement des données (l'écran est gardé vivant par un
/// IndexedStack, sinon les filtres arme/distance resteraient périmés).
final int refreshTick;
const StatisticsScreen({super.key, this.singleSession, this.refreshTick = 0});
@override
State<StatisticsScreen> createState() => _StatisticsScreenState();
@@ -18,7 +24,7 @@ class StatisticsScreen extends StatefulWidget {
class _StatisticsScreenState extends State<StatisticsScreen> {
final StatisticsService _statisticsService = StatisticsService();
StatsPeriod _selectedPeriod = StatsPeriod.all;
final StatsPeriod _selectedPeriod = StatsPeriod.all;
SessionStatistics? _statistics;
bool _isLoading = true;
List<Session> _allSessions = [];
@@ -29,12 +35,30 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> _availableWeapons = ['Toutes'];
List<String> _availableDistances = ['Toutes'];
// --- Comparateur de sessions ---
// Quand 2 sessions sont sélectionnées, l'écran passe en mode comparaison :
// un switch permet d'alterner l'affichage des stats entre la session A et B.
Session? _compareA;
Session? _compareB;
bool _showingB = false; // false = on affiche A, true = on affiche B
bool get _compareMode => _compareA != null && _compareB != null;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
}
@override
void didUpdateWidget(StatisticsScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour rafraîchir les listes
// de filtres (armes/distances) et les stats avec les nouvelles sessions.
if (oldWidget.refreshTick != widget.refreshTick) {
_loadStatistics();
}
}
Future<void> _loadStatistics() async {
if (!mounted) return;
setState(() => _isLoading = true);
@@ -79,6 +103,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
}
void _calculateStats() {
// Mode comparaison : on calcule les stats sur la seule session active
// (A ou B selon le switch), sans filtre de période.
if (_compareMode) {
final active = _showingB ? _compareB! : _compareA!;
_statistics = _statisticsService.calculateStatistics(
[active],
period: StatsPeriod.all,
);
return;
}
// Filtrer les sessions avant calcul
final filteredSessions = _allSessions.where((s) {
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
@@ -92,6 +127,80 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
);
}
String _sessionLabel(Session s) {
final d = s.createdAt;
final date = '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
return '${s.weapon}$date${s.totalScore} pts';
}
// Sélection des 2 sessions à comparer via un dialog à deux listes déroulantes.
Future<void> _openCompareDialog() async {
if (_allSessions.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Il faut au moins 2 sessions enregistrées pour comparer.')),
);
return;
}
Session? a = _compareA ?? _allSessions[0];
Session? b = _compareB ?? _allSessions[1];
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Comparer 2 sessions'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<Session>(
initialValue: a,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Session A'),
items: _allSessions
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() => a = v),
),
const SizedBox(height: 12),
DropdownButtonFormField<Session>(
initialValue: b,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Session B'),
items: _allSessions
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() => b = v),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Comparer')),
],
),
),
);
if (confirmed == true && a != null && b != null) {
setState(() {
_compareA = a;
_compareB = b;
_showingB = false;
_calculateStats();
});
}
}
void _exitCompareMode() {
setState(() {
_compareA = null;
_compareB = null;
_showingB = false;
_calculateStats();
});
}
List<double> _getScoreHistory() {
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
// Sort sessions by date and take last 10
@@ -123,47 +232,64 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 1. FILTRES (Arme et Distance)
Row(
children: [
Expanded(
child: _buildDropdown(
'Arme utilisée',
_selectedWeapon,
_availableWeapons,
(val) {
setState(() {
_selectedWeapon = val!;
_calculateStats();
});
},
: Column(
children: [
// HEADER COLLANT : filtres + comparateur toujours visibles
Material(
elevation: 2,
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
children: [
// 1. FILTRES (Arme et Distance)
Row(
children: [
Expanded(
child: _buildDropdown(
'Arme utilisée',
_selectedWeapon,
_availableWeapons,
(val) {
setState(() {
_selectedWeapon = val!;
_calculateStats();
});
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdown(
'Distance',
_selectedDistance,
_availableDistances,
(val) {
setState(() {
_selectedDistance = val!;
_calculateStats();
});
},
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdown(
'Distance',
_selectedDistance,
_availableDistances,
(val) {
setState(() {
_selectedDistance = val!;
_calculateStats();
});
},
),
),
],
),
const SizedBox(height: 20),
const SizedBox(height: 12),
// 1bis. COMPARATEUR DE SESSIONS
_buildComparator(),
],
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 2. DONNÉES RAPIDES (Tirs et Sessions)
Row(
children: [
@@ -191,18 +317,46 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
'Score',
'${_statistics?.totalScore ?? 0}',
_getScoreHistory(),
explanations: const [
MetricExplanation(
'Score',
'Total des points marqués sur la période sélectionnée.',
),
],
),
const SizedBox(height: 20),
_buildChartSection(
'Précision',
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
_getPrecisionHistory(),
explanations: const [
MetricExplanation(
'Précision',
'Proximité moyenne de vos impacts par rapport au centre '
'de la cible (calculée sur les distances).',
),
MetricExplanation(
'À ne pas confondre',
'C\'est différent de la « Réussite » affichée dans une '
'session, qui se base sur les points marqués. Les deux '
'mesurent des choses distinctes, leurs pourcentages '
'ne sont donc pas identiques.',
),
],
),
const SizedBox(height: 20),
_buildChartSection(
'Groupement moyen',
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%',
'Étalement moyen',
'${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
explanations: const [
MetricExplanation(
'Étalement moyen',
'Étalement moyen de vos groupements : distance entre les '
'impacts les plus éloignés, en % de la largeur de '
'l\'image. Plus c\'est bas, plus vos tirs sont serrés.',
),
],
),
const SizedBox(height: 25),
@@ -221,10 +375,101 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
],
const SizedBox(height: 30),
],
),
],
),
),
),
),
],
),
);
}
// Comparateur : bouton pour choisir 2 sessions, puis switch pour alterner.
Widget _buildComparator() {
final theme = Theme.of(context);
if (!_compareMode) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openCompareDialog,
icon: const Icon(Icons.compare_arrows),
label: const Text('Comparer 2 sessions'),
),
);
}
final activeColor = const Color(0xFF1A73E8);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: activeColor.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.compare_arrows, color: activeColor, size: 18),
const SizedBox(width: 6),
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)),
const Spacer(),
IconButton(
tooltip: 'Quitter la comparaison',
icon: const Icon(Icons.close, size: 18),
onPressed: _exitCompareMode,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
// Switch pour alterner entre les 2 sessions.
Row(
children: [
Expanded(
child: Text(
'A · ${_sessionLabel(_compareA!)}',
style: TextStyle(
fontSize: 12,
color: _showingB ? theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5) : null,
fontWeight: _showingB ? FontWeight.normal : FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: _showingB,
activeThumbColor: activeColor,
onChanged: (v) => setState(() {
_showingB = v;
_calculateStats();
}),
),
Expanded(
child: Text(
'B · ${_sessionLabel(_compareB!)}',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: _showingB ? null : theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5),
fontWeight: _showingB ? FontWeight.bold : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
'Affichage : session ${_showingB ? 'B' : 'A'}',
style: TextStyle(fontSize: 11, color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6)),
),
],
),
);
}
@@ -302,8 +547,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildChartSection(
String title,
String value,
List<double> dataPoints,
) {
List<double> dataPoints, {
List<MetricExplanation>? explanations,
}) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
@@ -314,9 +560,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
Row(
children: [
Text(
title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
if (explanations != null) ...[
const Spacer(),
MetricInfoButton(title: title, explanations: explanations),
],
],
),
const SizedBox(height: 10),
Row(

View File

@@ -7,10 +7,8 @@ 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 {
@@ -32,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(),
),

View File

@@ -15,25 +15,32 @@ class MainNavigationHolder extends StatefulWidget {
class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0;
final List<Widget> _screens = [
const HomeScreen(),
const HistoryScreen(),
const StatisticsScreen(),
const WeaponListScreen(),
];
// 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,
children: screens,
),
bottomNavigationBar: Container(
decoration: BoxDecoration(

View File

@@ -53,6 +53,8 @@ class AiExportService {
required double targetCenterY,
required double targetRadius,
required List<Shot> shots,
int distanceMeters = 25,
String weaponName = 'Unknown',
String? apiUrl,
}) async {
try {
@@ -109,8 +111,8 @@ class AiExportService {
"device_info": deviceData,
"target_metadata": {
"type": targetType.name,
"distance_meters": 25, // Default/placeholder
"weapon": "Unknown", // Default/placeholder
"distance_meters": distanceMeters,
"weapon": weaponName,
// The backend could extract exact width/height from the image.
},
"plotting": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +0,0 @@
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
/// Résultat du scan de document.
class DocumentScanResult {
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
final String? imagePath;
/// true si un scan a bien été produit.
bool get success => imagePath != null;
const DocumentScanResult(this.imagePath);
}
/// Service qui lance le scanner de documents Google ML Kit.
///
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
///
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
class DocumentScannerService {
/// Lance le scanner et renvoie le chemin de l'image redressée.
///
/// [pageLimit] est fixé à 1 (une seule cible par scan).
/// On désactive l'import galerie ici car la galerie est gérée séparément.
///
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
/// rehaussement type photocopie) → détruit les couleurs de la cible.
/// - `base` ne fait QUE le recadrage + le redressement de perspective
/// (mise de face), en conservant l'image en couleurs réelles. C'est
/// indispensable pour pouvoir détecter ensuite les impacts.
Future<DocumentScanResult> scanTarget() async {
final options = DocumentScannerOptions(
mode: ScannerMode.base, // redressement seul, sans filtre couleur
pageLimit: 1,
isGalleryImport: false,
);
final scanner = DocumentScanner(options: options);
try {
final DocumentScanningResult result = await scanner.scanDocument();
final images = result.images;
if (images.isEmpty) {
return const DocumentScanResult(null);
}
return DocumentScanResult(images.first);
} finally {
// Libère les ressources natives du scanner.
await scanner.close();
}
}
}

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

@@ -119,7 +119,6 @@ class ParallelismService {
// Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude;
final double ny = gy / magnitude;
final double nz = gz / magnitude;
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.

View File

@@ -1,461 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../data/models/target_type.dart';
import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart';
export 'image_processing_service.dart'
show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact;
// ============================================================================
// STRUCT ET FONCTION GLOBALE POUR LE PARALLÉLISME (THREAD SECONDAIRE)
// ============================================================================
/// Conteneur de données pour envoyer les paramètres à l'Isolate d'arrière-plan.
class DetectionPayload {
final String imagePath;
final TargetType targetType;
// On recrée les instances à l'intérieur du thread isolé car les objets ne se partagent pas entre threads.
DetectionPayload({
required this.imagePath,
required this.targetType,
});
}
/// FONCTION EXÉCUTÉE EN PARALLÈLE : Tourne sur un autre cœur du processeur.
/// L'interface graphique reste à 120 FPS et totalement fluide.
TargetDetectionResult runParallelTargetDetection(DetectionPayload payload) {
// 1. Initialisation locale des services dans le sous-thread
final imageProcessingService = ImageProcessingService();
try {
// 2. Détection de la cible principale (Calcul lourd)
final mainTarget = imageProcessingService.detectMainTarget(payload.imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
// 3. Détection des impacts (Calcul lourd)
final impacts = imageProcessingService.detectImpacts(payload.imagePath);
// 4. Calcul mathématique des scores relatifs
final detectedImpacts = impacts.map((impact) {
final score = payload.targetType == TargetType.concentric
? _staticCalculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _staticCalculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection parallèle: $e');
}
}
// Fonctions mathématiques pures nécessaires à l'Isolate (statiques)
int _staticCalculateConcentricScore(double impactX, double impactY, double centerX, double centerY, double targetRadius) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0;
}
int _staticCalculateSilhouetteScore(double impactX, double impactY, double centerX, double centerY) {
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
if (dx > 0.15) return 0;
if (dy < -0.25) return 5;
if (dy < 0.0) return 5;
if (dy < 0.15) return 4;
if (dy < 0.35) return 3;
return 0;
}
// ============================================================================
// FIN DU BLOC DE PARALLÉLISME
// ============================================================================
class TargetDetectionResult {
final double centerX; // Relative (0-1)
final double centerY; // Relative (0-1)
final double radius; // Relative (0-1)
final List<DetectedImpactResult> impacts;
final bool success;
final String? errorMessage;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
required this.impacts,
this.success = true,
this.errorMessage,
});
factory TargetDetectionResult.error(String message) {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
impacts: [],
success: false,
errorMessage: message,
);
}
}
class DetectedImpactResult {
final double x; // Relative (0-1)
final double y; // Relative (0-1)
final double radius; // Absolute pixels
final int suggestedScore;
DetectedImpactResult({
required this.x,
required this.y,
required this.radius,
required this.suggestedScore,
});
}
class TargetDetectionService {
final ImageProcessingService _imageProcessingService;
final OpenCVImpactDetectionService _opencvService;
TargetDetectionService({
ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file ASYNCHRONOUSLY in a separate Thread.
/// CORRECTION : Utilise désormais 'compute' pour basculer en arrière-plan immédiat.
Future<TargetDetectionResult> detectTargetAsync(String imagePath, TargetType targetType) async {
final payload = DetectionPayload(imagePath: imagePath, targetType: targetType);
// Déclenche l'exécution isolée en tâche de fond
return await compute(runParallelTargetDetection, payload);
}
/// Gardée pour rétrocompatibilité synchrone si nécessaire
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
try {
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
final impacts = _imageProcessingService.detectImpacts(imagePath);
final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection: $e');
}
}
int _calculateConcentricScore(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0;
}
int _calculateSilhouetteScore(
double impactX,
double impactY,
double centerX,
double centerY,
) {
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
if (dx > 0.15) return 0;
if (dy < -0.25) return 5;
if (dy < 0.0) return 5;
if (dy < 0.15) return 4;
if (dy < 0.35) return 3;
return 0;
}
List<DetectedImpactResult> detectImpactsOnly(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactDetectionSettings settings,
) {
try {
final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath,
settings,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
int _calculateConcentricScoreWithRings(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
int ringCount,
) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) {
return 10 - i;
}
}
return 0;
}
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references,
) {
return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
}
List<DetectedImpactResult> detectImpactsFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactCharacteristics characteristics, {
double tolerance = 2.0,
}) {
try {
final impacts = _imageProcessingService.detectImpactsFromReferences(
imagePath,
characteristics,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
List<DetectedImpactResult> detectImpactsWithOpenCV(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount, {
OpenCVDetectionSettings? settings,
}) {
try {
final impacts = _opencvService.detectImpacts(
imagePath,
settings: settings ?? const OpenCVDetectionSettings(),
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV: $e');
return [];
}
}
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
List<ReferenceImpact> references, {
double tolerance = 2.0,
}) {
try {
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
final impacts = _opencvService.detectFromReferences(
imagePath,
refPoints,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV depuis références: $e');
return [];
}
}
}

View File

@@ -1,259 +0,0 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:opencv_dart/opencv_dart.dart' as cv;
/// Résultat d'une tentative de redressement de cible.
class RectifyResult {
/// Chemin du fichier image redressé (ou original si échec).
final String outputPath;
/// true si une cible a été détectée et redressée, false si on a renvoyé
/// l'image d'origine sans transformation.
final bool rectified;
/// Angle d'inclinaison estimé de la cible AVANT redressement, en degrés.
/// (0 = déjà de face). Utile pour informer l'utilisateur.
final double estimatedTiltDegrees;
/// Message de diagnostic (utile pour debug / affichage).
final String message;
const RectifyResult({
required this.outputPath,
required this.rectified,
required this.estimatedTiltDegrees,
required this.message,
});
}
/// Service qui redresse une cible RONDE (cercles concentriques) photographiée
/// de biais, en la ramenant parfaitement de face.
///
/// Principe :
/// 1. Détecter le plus grand contour ~circulaire de l'image.
/// 2. Ajuster une ELLIPSE sur ce contour (fitEllipse).
/// 3. Un cercle vu en perspective devient une ellipse : on calcule la
/// transformation de perspective qui remappe cette ellipse vers un
/// CERCLE parfait, et on l'applique à toute l'image.
/// 4. La cible apparaît alors de face.
///
/// L'image résultat est carrée et centrée sur la cible.
class TargetRectifyService {
/// Taille (en pixels) du côté de l'image carrée de sortie.
final int outputSize;
/// Marge autour de la cible dans l'image de sortie (1.0 = cible pile au bord,
/// 1.3 = 30 % de marge autour). Garde un peu de contexte.
final double marginFactor;
/// En dessous de cet écart d'axes (ratio petit/grand axe proche de 1),
/// la cible est considérée déjà de face → pas de warp inutile.
final double minTiltRatioToRectify;
TargetRectifyService({
this.outputSize = 1024,
this.marginFactor = 1.25,
this.minTiltRatioToRectify = 0.985,
});
/// Redresse l'image située à [inputPath]. Écrit le résultat dans
/// [outputPath] et renvoie un [RectifyResult].
///
/// Ne bloque jamais : en cas d'échec de détection, renvoie l'image
/// d'origine (rectified = false) pour ne pas perdre la photo du tireur.
Future<RectifyResult> rectify({
required String inputPath,
required String outputPath,
}) async {
cv.Mat? src;
cv.Mat? gray;
cv.Mat? blurred;
cv.Mat? edges;
try {
src = cv.imread(inputPath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Image illisible',
);
}
// ── 1. Prétraitement ────────────────────────────────────────────────
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
blurred = cv.gaussianBlur(gray, (5, 5), 2, sigmaY: 2);
edges = cv.canny(blurred, 60, 160);
// Dilatation légère pour fermer les contours brisés
final cv.Mat kernel = cv.getStructuringElement(
cv.MORPH_ELLIPSE,
(3, 3),
);
final cv.Mat dilated = cv.dilate(edges, kernel);
// ── 2. Recherche du meilleur contour elliptique ──────────────────────
final (contours, _) = cv.findContours(
dilated,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final double imgArea = (src.width * src.height).toDouble();
cv.RotatedRect? bestEllipse;
double bestScore = 0;
for (int i = 0; i < contours.length; i++) {
final c = contours[i];
if (c.length < 5) continue; // fitEllipse exige >= 5 points
final double area = cv.contourArea(c);
// On ignore les contours minuscules et ceux qui couvrent presque tout
if (area < imgArea * 0.03 || area > imgArea * 0.97) continue;
final cv.RotatedRect e = cv.fitEllipse(c);
final ep = e.points; // 4 sommets
if (ep.length < 4) continue;
final double ecx = (ep[0].x + ep[1].x + ep[2].x + ep[3].x) / 4.0;
final double ecy = (ep[0].y + ep[1].y + ep[2].y + ep[3].y) / 4.0;
final double mAx = (ep[0].x + ep[1].x) / 2.0;
final double mAy = (ep[0].y + ep[1].y) / 2.0;
final double mBx = (ep[1].x + ep[2].x) / 2.0;
final double mBy = (ep[1].y + ep[2].y) / 2.0;
final double w =
2 * math.sqrt(math.pow(mAx - ecx, 2) + math.pow(mAy - ecy, 2));
final double h =
2 * math.sqrt(math.pow(mBx - ecx, 2) + math.pow(mBy - ecy, 2));
if (w <= 1 || h <= 1) continue;
// À quel point le contour ressemble-t-il vraiment à son ellipse ?
// On compare l'aire du contour à l'aire de l'ellipse ajustée.
final double ellipseArea = math.pi * (w / 2) * (h / 2);
if (ellipseArea <= 0) continue;
final double fitRatio = area / ellipseArea; // ~1 si bon ajustement
if (fitRatio < 0.7 || fitRatio > 1.3) continue;
// Score = taille de l'ellipse (on veut la cible la plus grande)
final double score = ellipseArea;
if (score > bestScore) {
bestScore = score;
bestEllipse = e;
}
}
if (bestEllipse == null) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Aucune cible circulaire détectée',
);
}
// ── 3. Extraire les 4 sommets de l'ellipse (robuste à la version d'API) ─
// RotatedRect.points renvoie les 4 coins de la boîte englobant l'ellipse.
// On en dérive nous-mêmes le centre, les demi-axes et l'orientation, ce
// qui évite de dépendre de la forme exacte de `.size` / `.center`
// (record vs objet) qui varie selon les versions d'opencv_dart.
final pts = bestEllipse.points; // List<Point2f> de 4 sommets
double px(int i) => pts[i].x;
double py(int i) => pts[i].y;
// Centre = moyenne des 4 sommets
final double cx = (px(0) + px(1) + px(2) + px(3)) / 4.0;
final double cy = (py(0) + py(1) + py(2) + py(3)) / 4.0;
// Milieux de deux côtés adjacents → extrémités des deux demi-axes
// Côté 0-1 et côté 1-2 (ordre des sommets d'un RotatedRect)
final double m01x = (px(0) + px(1)) / 2.0;
final double m01y = (py(0) + py(1)) / 2.0;
final double m12x = (px(1) + px(2)) / 2.0;
final double m12y = (py(1) + py(2)) / 2.0;
// Demi-axes = distance centre → milieu de chaque côté
final double axisA =
math.sqrt(math.pow(m01x - cx, 2) + math.pow(m01y - cy, 2));
final double axisB =
math.sqrt(math.pow(m12x - cx, 2) + math.pow(m12y - cy, 2));
final double majorAxis = math.max(axisA, axisB);
final double minorAxis = math.min(axisA, axisB);
if (majorAxis <= 1) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Ellipse dégénérée',
);
}
final double axisRatio = minorAxis / majorAxis; // 1 = cercle parfait
final double tiltDeg =
math.acos(axisRatio.clamp(0.0, 1.0)) * (180.0 / math.pi);
// Déjà quasiment de face → on ne touche pas (évite le flou inutile)
if (axisRatio >= minTiltRatioToRectify) {
cv.imwrite(outputPath, src);
return RectifyResult(
outputPath: outputPath,
rectified: false,
estimatedTiltDegrees: tiltDeg,
message: 'Cible déjà de face',
);
}
// ── 4. Construire la transformation de perspective ────────────────────
// Source : extrémités des deux axes de l'ellipse (4 points).
// Destination : extrémités des axes d'un cercle parfait centré.
// On obtient les extrémités en prolongeant centre→milieu-de-côté.
final srcPts = cv.VecPoint.fromList([
cv.Point((cx + (m01x - cx)).round(), (cy + (m01y - cy)).round()),
cv.Point((cx - (m01x - cx)).round(), (cy - (m01y - cy)).round()),
cv.Point((cx + (m12x - cx)).round(), (cy + (m12y - cy)).round()),
cv.Point((cx - (m12x - cx)).round(), (cy - (m12y - cy)).round()),
]);
// Cible : cercle parfait centré, rayon R, dans une image carrée.
// L'axe "A" (m01) devient l'axe horizontal, l'axe "B" (m12) le vertical.
final double out = outputSize.toDouble();
final double center = out / 2;
final double radius = (out / 2) / marginFactor;
final dstPts = cv.VecPoint.fromList([
cv.Point((center + radius).round(), center.round()),
cv.Point((center - radius).round(), center.round()),
cv.Point(center.round(), (center + radius).round()),
cv.Point(center.round(), (center - radius).round()),
]);
final cv.Mat transform = cv.getPerspectiveTransform(srcPts, dstPts);
final cv.Mat warped = cv.warpPerspective(
src,
transform,
(outputSize, outputSize),
flags: cv.INTER_LINEAR,
borderMode: cv.BORDER_CONSTANT,
);
cv.imwrite(outputPath, warped);
return RectifyResult(
outputPath: outputPath,
rectified: true,
estimatedTiltDegrees: tiltDeg,
message: 'Cible redressée (inclinaison ${tiltDeg.toStringAsFixed(1)}°)',
);
} catch (e) {
debugPrint('TargetRectify erreur: $e');
// En cas de pépin, on renvoie l'original pour ne jamais perdre la photo
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Erreur de traitement: $e',
);
}
}
}

View File

@@ -293,30 +293,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_mlkit_commons:
dependency: transitive
description:
name: google_mlkit_commons
sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
google_mlkit_document_scanner:
dependency: "direct main"
description:
name: google_mlkit_document_scanner
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
google_mlkit_object_detection:
dependency: "direct main"
description:
name: google_mlkit_object_detection
sha256: "9dd35886972e18747e22098f8ebee78d30716a99a789bb2e3a65a24229e031e7"
url: "https://pub.dev"
source: hosted
version: "0.15.1"
hooks:
dependency: transitive
description:

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
@@ -38,11 +38,9 @@ dependencies:
cupertino_icons: ^1.0.8
sensors_plus: ^4.0.2
opencv_dart: ^2.1.0
google_mlkit_object_detection: ^0.15.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
@@ -73,9 +71,6 @@ dependencies:
crypto: ^3.0.7
camera: ^0.12.0+1
# Machine Learning for YOLOv8
# tflite_flutter: ^0.11.0
dev_dependencies:
flutter_test:
sdk: flutter
@@ -100,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);
}