Compare commits

...
82 Commits
Author SHA1 Message Date
qlionbleusamandClaude Opus 5 0374a7611e fix: ajoute backup_service oublie lors du commit precedent
Le commit 543f54dc livrait l'ecran Statistiques qui importe
services/backup_service.dart, mais le service et son test n'avaient
jamais ete ajoutes a l'index : la branche poussee ne compilait pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:30:13 +02:00
qlionbleusam 543f54dc4f feat: export/import JSON des sessions, stats et armurerie via partage systeme 2026-08-13 12:22:42 +02:00
qlionbleusam 3812cd740b feat: tap simple toujours createur d'impact + ecran de plotting renomme Synthese 2026-08-07 12:04:49 +02:00
qlionbleusam 86abb645ce feat: popup de fin de session aux couleurs de l'appli avec export IA integre 2026-08-05 03:02:40 +02:00
qlionbleusam 3a9e393a20 feat: score de session en plotting + guidage pour placer le premier impact 2026-08-04 16:25:44 +02:00
qlionbleusam 058bd5ff71 feat: boutons de rotation fine sur l'ecran de centrage + consigne de cadrage 2026-08-04 16:06:05 +02:00
qlionbleusam a4dc26fda8 refactor: reglages de calibration au-dessus de la photo + boutons de taille fonctionnels 2026-08-04 15:57:10 +02:00
qlionbleusam 6130e63cfb feat: rediriger vers l'onglet Statistiques a la fin d'une session 2026-08-04 15:38:14 +02:00
qlionbleusamandClaude Opus 4.8 b641e80d62 feat: boutons valider flottants + reset impacts dans l'éditeur, export IA dans la popup fin de session
- VALIDER en bouton bleu flottant sur calibration et placement des impacts
- corbeille (reset impacts) à côté du VALIDER dans l'éditeur
- retrait des boutons refresh/cloud de l'AppBar du plotting
- export IA déplacé dans la popup de fin de session

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:22:37 +02:00
qlionbleusamandClaude Opus 4.8 df5f84c87c refactor: flux calibration -> plotting -> placement des impacts
- VALIDER en calibration mène directement au plotting
- l'éditeur d'impacts revient toujours au plotting au retour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:39:19 +02:00
qlionbleusam bbf10d7eed refactor: cible du plotting cliquable et fixe, retrait bouton modifier impacts 2026-07-22 16:15:25 +02:00
qguillaume 2b904205a0 fix: reset espacement calibration + refresh auto historique 2026-07-04 11:22:50 +02:00
qguillaumeandClaude Fable 5 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
qguillaumeandClaude Fable 5 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
qguillaumeandClaude Fable 5 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
qguillaumeandClaude Fable 5 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
qguillaumeandClaude Opus 4.8 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
qguillaumeandClaude Opus 4.8 1d8124c8d8 fix: clarifie les libelles de stats et corrige le calcul de l etalement moyen
- score_card: "Pourcentage" -> "Reussite", ajoute "/10" sur la moyenne
- grouping_stats: "Diametre" -> "Etalement", direction du decalage en toutes lettres (Droite, Haut-Droite...) au lieu de D/G/H/B
- statistics: corrige le bug de priorite (... ?? 0 * 100) qui affichait un etalement moyen errone (~0.9% au lieu de ~90%)
- ajoute un bouton d aide reutilisable (MetricInfoButton) expliquant chaque metrique, dont la distinction Precision (distance) / Reussite (score)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:27:10 +02:00
qguillaume 600a6bef30 fix: ratio aspect camera rectangulaire 2026-06-12 11:33:41 +02:00
qguillaume 5d2e612b9c fix: reeduction temps prise photo et enleve confusion photo en cours de traitement 2026-06-12 11:17:16 +02:00
qguillaume 4e26431be5 fix: inclinaison 2 à 3 degrés vert orange 2026-06-11 21:50:06 +02:00
qguillaume 559369c84d fix: recentrage incorrect si zoom 2026-06-11 21:21:35 +02:00
qguillaume 653aa5d5b0 fix: recentrage incorrect si loin du centre d origine 2026-06-11 21:02:47 +02:00
qguillaume fec33a327a fix: recentrage incorrect si loin du centre d origine 2026-06-11 20:47:47 +02:00
qguillaume 8e64839fc6 fix: capture photo plus robuste 2026-06-11 20:24:57 +02:00
qguillaume 331fda0ffb fix: freeze et deformation lors de la prise de la photo (rotation) 2026-06-11 20:04:25 +02:00
qguillaume 064f5c1fe3 chore: ignorer le dossier de configuration .claude 2026-06-11 19:46:11 +02:00
qguillaume b4226216eb feat: retrait cercle et encadre + decalage centrage de la cible pris en compte 2026-06-11 19:40:30 +02:00
qguillaume 2107f187b6 feat: adding new plotting screen fixing pan and tap 2026-06-09 00:12:10 +02:00
qguillaume 0c509bfdc3 fix: trying to avoid conflict first with tap and pin on plotting screen in progress yet 2026-06-08 23:43:33 +02:00
qguillaume 71ad670ad8 fix: trying to avoid conflict first with tap and pin on plotting screen 2026-06-08 23:27:44 +02:00
qguillaume 625810234e fix: light zoom at back 2026-06-08 23:09:47 +02:00
qguillaume 9afe3c8508 fix: opacity 80% for impacts + lower quality for pics + removing slowly zoom if you back go back 2026-06-08 22:30:51 +02:00
qguillaume f0b15941cf fix: on revire ml kit on essaie d ameliorer la detection des cercles 2026-06-05 23:05:11 +02:00
qguillaume 9f9f9beb84 feat: target rectify service pour perfectionner la prise de photo avec detection 2026-06-05 22:27:53 +02:00
qguillaume c49c8b8c73 fix: cadre toujours orange 2026-06-05 22:06:05 +02:00
qguillaume 61b5fcfe22 fix: ajout untrack files 2026-06-05 21:30:59 +02:00
qguillaume f65c5e6a3f feat: object detection service 2026-06-05 21:18:48 +02:00
qguillaume d487c98e3d fix: parallelisme service permissif et correctif capture screen 2026-06-05 20:33:04 +02:00
qguillaume 9cb37fc303 feat: construction du service parallelisme 2026-06-05 19:47:18 +02:00
qguillaume 3ae9ceebc1 fix: lenteur de l appli capture screen 2026-06-05 17:38:25 +02:00
qguillaume 251d4bb599 fix: lenteur de l appli 2026-06-05 17:18:29 +02:00
qguillaume ccc6eb609a fix(analysis): correctif réel du zoom post prise de photo 2026-06-05 07:42:07 +02:00
qguillaume 9e0427f750 fix(analysis): normaliser les coordonnées de la cible pour annuler l'effet du zoom initial 2026-06-04 23:32:38 +02:00
qguillaume a5d0251545 fix(camera): synchroniser le ratio de visée 3/4 avec le capteur natif et corriger le décalage du crop 2026-05-27 17:44:57 +02:00
qguillaume 730629a031 fix(crop): stabiliser le rectangle géométrique de découpe et l'enchaînement des zooms 2026-05-27 15:36:56 +02:00
qguillaume 4d2ca2c94f refactor(crop_screen): restrict rotation bounds to 15 degrees with high-precision decimal steps 2026-05-27 14:48:57 +02:00
qguillaume ab45786d0c fix(analysis): appliquer la transformation globale sur le GestureDetector pour restaurer le drag-and-drop 2026-05-27 12:16:35 +02:00
qguillaume 95e62abe47 fix(analysis): appliquer la rotation du crop sur l'image en modes calibration et plotting 2026-05-27 12:11:00 +02:00
qguillaume 04c1204116 fix(calibration): préserver les rayons d'origine de l'IA lors du redimensionnement et corriger le reset 2026-05-27 11:56:07 +02:00
qguillaume 78c9fc96c9 feat(session): ajout du sélecteur de date et persistance dans l'historique de tir 2026-05-27 00:57:26 +02:00
qguillaume e570bd4296 fix(calibration): application de la rotation et du centrage sur l'image globale 2026-05-27 00:23:48 +02:00
qguillaume 02c0d449e4 fix(calibration): application du crop sur l'écran de calibration 2026-05-27 00:03:29 +02:00
qguillaume eabe7511bd feat(crop): masque opaque à coins droits pour cacher l'extérieur du cadre de découpe 2026-05-26 23:41:06 +02:00
qguillaume 620e12a1fd feat(crop): ajout de lignes de visée perpendiculaires sur toute la zone pour aligner la cible 2026-05-26 23:36:50 +02:00
qguillaume 622f2936de ui(plotting): décalage visuel de l'impact lors du glisser pour éviter de le cacher avec le doigt 2026-05-26 23:23:45 +02:00
qguillaume 814f778e6c feat(crop): remplacement de la rotation à deux doigts par une jauge précise centrée à zéro 2026-05-26 22:52:59 +02:00
qguillaume 2f493ef622 feat(setup): choix d'arme obligatoire, redirection armurerie et max balles à 50 2026-05-26 22:41:44 +02:00
qguillaume 33e1522df0 reword: Démarrer la session (home_screen) devient Nouvelle session 2026-05-26 22:29:37 +02:00
qguillaume 653264c6a1 Refactor: Amélioration globale de l'expérience de cadrage et de l'interface de capture 2026-05-26 18:09:39 +02:00
qguillaume 750732eafe Feat: Ajout de la rotation à deux doigts de l'image sur l'écran de Crop 2026-05-26 17:07:16 +02:00
qguillaume f40bfc0ba7 Feat: Ajout du glisser-déposer par appui long pour repositionner les impacts de tir 2026-05-26 17:03:21 +02:00
qguillaume b721583e98 Feat: Ajout du double-tap sur l'écran d'analyse pour l'édition rapide des impacts 2026-05-26 16:47:37 +02:00
qguillaume 18c01c3767 Style: Optimisation des curseurs de calibration et bridage des limites 2026-05-26 16:40:14 +02:00
qguillaume 0af6a0d8e2 Feat: Bouton de clôture de session (80/20) sur l'accueil et reset de l'espacement 2026-05-26 14:59:25 +02:00
qguillaume fbd631c8e4 Workflow: Inversion du flux pour calibrer la cible avant d'afficher l'écran de Plotting 2026-05-25 13:52:20 +02:00
qguillaume 3088c5ee91 Refactor: Ajustement des valeurs limites de taille (0.5 à 1.1) pour synchroniser le slider et le geste tactile 2026-05-25 13:14:51 +02:00
qguillaume fe6d52902b Refactor: Optimisation du range du slider de Taille pour améliorer la précision de calibration 2026-05-25 13:00:04 +02:00
qguillaume 48b8313be8 Feat: Ajout d'un switch pour masquer ou afficher le slider d'espacement de la cible 2026-05-25 03:12:37 +02:00
qguillaume a9ff826246 Design: Remplacement du point rouge par une mire vert fluo interne dans le viseur 2026-05-25 02:59:46 +02:00
qguillaume f149fe092b Fix: Passage à l'appareil photo classique et suppression de l'auto-capture 2026-05-25 02:43:31 +02:00
88 changed files with 5937 additions and 5945 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(flutter analyze:*)"
]
}
}
-16
View File
@@ -1,16 +0,0 @@
{
"permissions": {
"allow": [
"Bash(flutter clean:*)",
"Bash(flutter pub get:*)",
"Bash(flutter run:*)",
"Bash(cmake:*)",
"Bash(where:*)",
"Bash(winget search:*)",
"Bash(winget install:*)",
"Bash(\"/c/Program Files \\(x86\\)/Microsoft Visual Studio/Installer/vs_installer.exe\" modify --installPath \"C:\\\\Program Files \\(x86\\)\\\\Microsoft Visual Studio\\\\2022\\\\BuildTools\" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait)",
"Bash(cmd //c \"\"\"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\18\\\\Community\\\\Common7\\\\Tools\\\\VsDevCmd.bat\"\" && flutter run -d windows\")",
"Bash(flutter doctor:*)"
]
}
}
+1
View File
@@ -11,6 +11,7 @@
.svn/ .svn/
.swiftpm/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
.claude/
# IntelliJ related # IntelliJ related
*.iml *.iml
+25 -10
View File
@@ -41,10 +41,12 @@ flutter test --coverage
## Features ## Features
### Analyse de cibles de tir ### Capture de cibles de tir
- Support de cibles concentriques (anneaux) et silhouettes - Support de cibles concentriques (anneaux) et silhouettes
- Chargement d'images de cibles depuis la galerie ou la caméra - Chargement d'images de cibles depuis la galerie ou la caméra
- Détection automatique du centre et du rayon de la cible - Aperçu caméra avec aide au cadrage : détection OpenCV de la cible (mire)
et indicateur de parallélisme par accéléromètre (pitch/roll)
- Écran de centrage/recadrage : rotation fine, déplacement pixel par pixel
### Calibration des cibles ### Calibration des cibles
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10) - Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
@@ -52,14 +54,13 @@ flutter test --coverage
- Slider global pour redimensionner tous les anneaux proportionnellement - Slider global pour redimensionner tous les anneaux proportionnellement
- Visualisation en temps réel des zones de score - Visualisation en temps réel des zones de score
### Détection d'impacts ### Placement des impacts
- **Ajout manuel** : cliquer sur l'image pour placer un impact - **Éditeur d'impacts plein écran** : tap pour ajouter (y compris juste à côté
- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables ou par-dessus un impact existant), appui long pour déplacer, pincer pour zoomer
- Seuil de luminosité - Un tap n'ouvre jamais d'édition de score : le score reste calculé
- Taille min/max des impacts automatiquement d'après la position de l'impact
- Circularité minimale - Le placement est entièrement manuel ; le bouton ↻ de l'écran de synthèse
- Ratio de remplissage (distingue les trous pleins des cercles vides) efface tous les impacts sans toucher à la calibration
- **Détection par références** : sélectionner 2-4 impacts manuellement, l'algorithme apprend leurs caractéristiques et détecte les impacts similaires
### Calcul des scores ### Calcul des scores
- Score automatique basé sur la position de l'impact dans les zones - Score automatique basé sur la position de l'impact dans les zones
@@ -79,6 +80,19 @@ flutter test --coverage
- **Distribution régionale** : répartition des tirs par quadrant - **Distribution régionale** : répartition des tirs par quadrant
- Filtrage par période : session, semaine, mois, toutes les sessions - Filtrage par période : session, semaine, mois, toutes les sessions
### Sauvegarde (export / import JSON)
- Bouton **Exporter** en bas de l'écran Statistiques : génère un fichier JSON
(sessions + cibles + impacts + calibration, armurerie + entretien, et un
instantané des statistiques calculées) puis ouvre la feuille de partage du
système (`share_plus`) pour l'envoyer où l'on veut
- Option « Inclure les photos des cibles » : photos encodées en base64 dans le
JSON (sauvegarde complète mais fichier lourd) ; sans elles le fichier reste léger
- Bouton **Importer** (`file_selector`) : aperçu du contenu avant confirmation,
puis fusion avec les données existantes — même identifiant = mise à jour,
donc réimporter deux fois ne crée pas de doublon et rien n'est effacé
- Les statistiques ne sont pas réimportées : elles sont recalculées à partir des
sessions. Elles figurent dans le fichier pour être exploitables telles quelles
### Historique des sessions ### Historique des sessions
- Sauvegarde des sessions avec date, score, notes - Sauvegarde des sessions avec date, score, notes
- Visualisation des sessions passées - Visualisation des sessions passées
@@ -120,3 +134,4 @@ session_list_item.dart Item de liste représentant une session
history_chart.dart Graphique d'évolution des 10 dernières sessions history_chart.dart Graphique d'évolution des 10 dernières sessions
statistics_screen.dart Écran statistiques avec filtrage par période statistics_screen.dart Écran statistiques avec filtrage par période
heat_map_widget.dart Heat map avec gradient bleu→rouge heat_map_widget.dart Heat map avec gradient bleu→rouge
backup_service.dart Export/import JSON des sessions, stats et armurerie
Binary file not shown.
-31
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.
-37
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
-38
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1 -3
View File
@@ -13,8 +13,7 @@ dashboard/out/
.env.test.local .env.test.local
.env.production.local .env.production.local
# Database # Database (contient des données utilisateurs — jamais dans git)
backendia/uploads/data/database.sqlite
*.sqlite *.sqlite
*.sqlite-journal *.sqlite-journal
@@ -23,7 +22,6 @@ uploads/images/*
!uploads/images/.gitkeep !uploads/images/.gitkeep
uploads/data/* uploads/data/*
!uploads/data/.gitkeep !uploads/data/.gitkeep
!uploads/data/database.sqlite
exports/* exports/*
!exports/.gitkeep !exports/.gitkeep
@@ -1,116 +0,0 @@
{
"session_id": "session_1777452202935",
"timestamp": "2026-04-29T08:43:22.943303",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3934941044560185,
"norm_y": 0.665676540798611
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.6953124999999999
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.5249565972222221
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.6759033203125,
"norm_y": 0.4786376953125
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.48608398437499994,
"norm_y": 0.5490315755208333
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5101625795717593,
"norm_y": 0.43238661024305547
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6296079282407407,
"norm_y": 0.4203152126736111
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.46386718749999994,
"norm_y": 0.48046875
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3379521122685185,
"norm_y": 0.4397786458333333
}
}
]
}
}
@@ -1,116 +0,0 @@
{
"session_id": "session_1777452481252",
"timestamp": "2026-04-29T08:48:01.259705",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3934941044560185,
"norm_y": 0.665676540798611
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.6953124999999999
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.5249565972222221
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.6759033203125,
"norm_y": 0.4786376953125
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.48608398437499994,
"norm_y": 0.5490315755208333
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5101625795717593,
"norm_y": 0.43238661024305547
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6296079282407407,
"norm_y": 0.4203152126736111
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.46386718749999994,
"norm_y": 0.48046875
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3379521122685185,
"norm_y": 0.4397786458333333
}
}
]
}
}
@@ -1,44 +0,0 @@
{
"session_id": "session_1777452613625",
"timestamp": "2026-04-29T08:50:13.631206",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 10,
"coords": {
"norm_x": 0.49810836226851846,
"norm_y": 0.4981011284722222
}
}
]
}
}
@@ -1,144 +0,0 @@
{
"session_id": "session_1777463308809",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T11:48:28.834236",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.3666042751736111
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.396240234375
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5860903139467593,
"norm_y": 0.5999620225694443
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.33517433449074074,
"norm_y": 0.5592041015625
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.6073612919560185,
"norm_y": 0.5101047092013888
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.432373046875,
"norm_y": 0.46663411458333337
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.45181297019675926,
"norm_y": 0.5564236111111112
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 10,
"coords": {
"norm_x": 0.5221860532407407,
"norm_y": 0.48792860243055547
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.6564335575810184,
"norm_y": 0.7536349826388888
}
},
{
"id": 10,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.4712528935185185,
"norm_y": 0.7018229166666666
}
},
{
"id": 11,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.3981327763310185,
"norm_y": 0.7018229166666666
}
},
{
"id": 12,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.48144531249999994,
"norm_y": 0.8212483723958334
}
}
]
}
}
@@ -1,117 +0,0 @@
{
"session_id": "session_1777463703756",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T11:55:03.765285",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.45178222656249994
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6518247251157407,
"norm_y": 0.3638237847222222
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5490415219907407,
"norm_y": 0.66845703125
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.405517578125,
"norm_y": 0.6758490668402777
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.7268066406249999,
"norm_y": 0.6758490668402777
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5471806278935185,
"norm_y": 0.529568142361111
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.44720504195601846,
"norm_y": 0.5980631510416666
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.4221462673611111
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5203251591435185,
"norm_y": 0.3638237847222222
}
}
]
}
}
@@ -1,117 +0,0 @@
{
"session_id": "session_1777464156234",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T12:02:36.255849",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.45178222656249994
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6518247251157407,
"norm_y": 0.3638237847222222
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5490415219907407,
"norm_y": 0.66845703125
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.405517578125,
"norm_y": 0.6758490668402777
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.7268066406249999,
"norm_y": 0.6758490668402777
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5471806278935185,
"norm_y": 0.529568142361111
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.44720504195601846,
"norm_y": 0.5980631510416666
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.4221462673611111
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5203251591435185,
"norm_y": 0.3638237847222222
}
}
]
}
}
@@ -1,180 +0,0 @@
{
"session_id": "session_1777464607256",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T12:10:07.260954",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.7388509114583333
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.3184814453125,
"norm_y": 0.6489935980902778
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3110966435185185,
"norm_y": 0.529568142361111
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.4416097005208333
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.34252929687499994
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.32589699074074074,
"norm_y": 0.2332763671875
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.19624837239583331
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.23049587673611108
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6907045717592593,
"norm_y": 0.3546006944444444
}
},
{
"id": 10,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.699951171875,
"norm_y": 0.48046875
}
},
{
"id": 11,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6268310546874999,
"norm_y": 0.5416395399305556
}
},
{
"id": 12,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.49072265624999994,
"norm_y": 0.5610351562499999
}
},
{
"id": 13,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.4203495732060185,
"norm_y": 0.536960177951389
}
},
{
"id": 14,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5370180483217593,
"norm_y": 0.6879204644097222
}
},
{
"id": 15,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.6175537109375,
"norm_y": 0.7601453993055556
}
},
{
"id": 16,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.7027280454282406,
"norm_y": 0.8406439887152777
}
}
]
}
}
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

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

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
@@ -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

+1 -2
View File
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'main_navigation_holder.dart'; import 'main_navigation_holder.dart';
import 'features/home/home_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
class BullyApp extends StatelessWidget { class BullyApp extends StatelessWidget {
@@ -28,7 +27,7 @@ class BullyApp extends StatelessWidget {
Locale('fr', 'FR'), // Français Locale('fr', 'FR'), // Français
], ],
locale: const Locale('fr', 'FR'), // Force l'interface en français locale: const Locale('fr', 'FR'), // Force l'interface en français
home: const MainNavigationHolder(), home: MainNavigationHolder(key: mainNavKey),
); );
}, },
); );
+76
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'),
),
],
),
);
}
}
+98 -10
View File
@@ -10,6 +10,12 @@ import '../../core/constants/app_constants.dart';
class DatabaseHelper { class DatabaseHelper {
static DatabaseHelper? _instance; static DatabaseHelper? _instance;
static Database? _database; static Database? _database;
// On met en cache le Future d'initialisation (et non la Database résolue)
// pour éviter qu'un démarrage concurrent (les 4 onglets de l'IndexedStack
// interrogent la base en même temps) ne lance plusieurs _initDatabase() en
// parallèle. Sur une base fraîche, cela dédoublait onCreate et rendait la
// toute première écriture peu fiable.
static Future<Database>? _initFuture;
DatabaseHelper._internal(); DatabaseHelper._internal();
@@ -19,7 +25,9 @@ class DatabaseHelper {
} }
Future<Database> get database async { Future<Database> get database async {
_database ??= await _initDatabase(); if (_database != null) return _database!;
_initFuture ??= _initDatabase();
_database = await _initFuture!;
return _database!; return _database!;
} }
@@ -285,9 +293,14 @@ class DatabaseHelper {
Future<int> insertSession(Session session) async { Future<int> insertSession(Session session) async {
final db = await database; final db = await database;
return await db.transaction((txn) async { return await db.transaction((txn) async {
// INTERCEPTION ET FIX DE LA DATE ICI :
// On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet
final Map<String, dynamic> sessionMap = Map.from(session.toMap());
sessionMap['created_at'] = session.createdAt.toIso8601String();
await txn.insert( await txn.insert(
AppConstants.sessionsTable, AppConstants.sessionsTable,
session.toMap(), sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct !
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
@@ -367,17 +380,71 @@ class DatabaseHelper {
limit: limit, limit: limit,
offset: offset, offset: offset,
); );
if (sessionMaps.isEmpty) return [];
final sessions = <Session>[]; // 3 requêtes groupées au lieu d'une cascade par session puis par analyse
for (final sessionMap in sessionMaps) { // (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
final sessionId = sessionMap['id'] as String; // ce qui ralentissait l'accueil/historique/stats au fil des mois.
final session = await getSession(sessionId); final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
if (session != null) { final analysisMaps = await _queryIn(
sessions.add(session); db,
} AppConstants.targetAnalysesTable,
'session_id',
sessionIds,
);
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
final shotMaps = await _queryIn(
db,
AppConstants.shotsTable,
'analysis_id',
analysisIds,
);
final shotsByAnalysis = <String, List<Shot>>{};
for (final map in shotMaps) {
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
.add(Shot.fromMap(map));
} }
return sessions; final analysesBySession = <String, List<TargetAnalysis>>{};
for (final map in analysisMaps) {
final analysis = TargetAnalysis.fromMap(
map,
shotsByAnalysis[map['id'] as String] ?? [],
);
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
}
return sessionMaps
.map((m) =>
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
.toList();
}
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
Future<List<Map<String, Object?>>> _queryIn(
Database db,
String table,
String column,
List<String> values,
) async {
if (values.isEmpty) return [];
const chunkSize = 500;
final results = <Map<String, Object?>>[];
for (var i = 0; i < values.length; i += chunkSize) {
final chunk = values.sublist(
i,
i + chunkSize > values.length ? values.length : i + chunkSize,
);
final placeholders = List.filled(chunk.length, '?').join(',');
results.addAll(await db.query(
table,
where: '$column IN ($placeholders)',
whereArgs: chunk,
));
}
return results;
} }
Future<int> deleteSession(String id) async { Future<int> deleteSession(String id) async {
@@ -504,6 +571,16 @@ class DatabaseHelper {
return Sqflite.firstIntValue(result) ?? 0; return Sqflite.firstIntValue(result) ?? 0;
} }
Future<int> getSessionCountForWeapon(String weaponId) async {
final db = await database;
final result = await db.rawQuery('''
SELECT COUNT(id) as count
FROM ${AppConstants.sessionsTable}
WHERE weapon_id = ?
''', [weaponId]);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> insertMaintenance(MaintenanceEntry entry) async { Future<int> insertMaintenance(MaintenanceEntry entry) async {
final db = await database; final db = await database;
return await db.insert( return await db.insert(
@@ -513,6 +590,16 @@ class DatabaseHelper {
); );
} }
/// Toutes les entrées de maintenance, armes confondues (export/sauvegarde).
Future<List<MaintenanceEntry>> getAllMaintenance() async {
final db = await database;
final maps = await db.query(
AppConstants.maintenanceTable,
orderBy: 'date DESC',
);
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
}
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async { Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
final db = await database; final db = await database;
final maps = await db.query( final maps = await db.query(
@@ -537,5 +624,6 @@ class DatabaseHelper {
final db = await database; final db = await database;
await db.close(); await db.close();
_database = null; _database = null;
_initFuture = null;
} }
} }
+46 -2
View File
@@ -27,6 +27,7 @@ class SessionRepository {
required List<TargetAnalysis> analyses, required List<TargetAnalysis> analyses,
String? notes, String? notes,
int distance = 25, int distance = 25,
DateTime? date,
}) async { }) async {
final session = Session( final session = Session(
id: id ?? _uuid.v4(), id: id ?? _uuid.v4(),
@@ -34,7 +35,7 @@ class SessionRepository {
weaponId: weaponId, weaponId: weaponId,
maxShotsPerTarget: maxShotsPerTarget, maxShotsPerTarget: maxShotsPerTarget,
analyses: analyses, analyses: analyses,
createdAt: DateTime.now(), createdAt: date ?? DateTime.now(),
notes: notes, notes: notes,
distance: distance, distance: distance,
); );
@@ -130,6 +131,29 @@ class SessionRepository {
return await _databaseHelper.getStatistics(); return await _databaseHelper.getStatistics();
} }
/// Enregistre une session déjà construite (import de sauvegarde).
/// Les identifiants existants sont écrasés : réimporter deux fois la même
/// sauvegarde ne crée pas de doublons.
Future<void> saveSession(Session session) async {
await _databaseHelper.insertSession(session);
}
/// Copie une image dans le dossier des cibles de l'app (import de sauvegarde).
Future<String> saveImageBytes(List<int> bytes, String extension) async {
final appDir = await getApplicationDocumentsDirectory();
final imagesDir = Directory(path.join(appDir.path, 'target_images'));
if (!await imagesDir.exists()) {
await imagesDir.create(recursive: true);
}
final fileName = '${_uuid.v4()}$extension';
final destPath = path.join(imagesDir.path, fileName);
await File(destPath).writeAsBytes(bytes);
return destPath;
}
String generateId() { String generateId() {
return _uuid.v4(); return _uuid.v4();
} }
@@ -169,6 +193,11 @@ class SessionRepository {
return await _databaseHelper.getAllWeapons(); return await _databaseHelper.getAllWeapons();
} }
/// Enregistre une arme déjà construite (import de sauvegarde).
Future<void> saveWeapon(Weapon weapon) async {
await _databaseHelper.insertWeapon(weapon);
}
Future<void> updateWeapon(Weapon weapon) async { Future<void> updateWeapon(Weapon weapon) async {
await _databaseHelper.updateWeapon(weapon); await _databaseHelper.updateWeapon(weapon);
} }
@@ -181,18 +210,23 @@ class SessionRepository {
return await _databaseHelper.getRoundsFiredForWeapon(weaponId); return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
} }
Future<int> getSessionCountForWeapon(String weaponId) async {
return await _databaseHelper.getSessionCountForWeapon(weaponId);
}
Future<void> addMaintenanceEntry({ Future<void> addMaintenanceEntry({
required String weaponId, required String weaponId,
required MaintenanceType type, required MaintenanceType type,
required String description, required String description,
int? roundsSinceLast, int? roundsSinceLast,
DateTime? date,
}) async { }) async {
final entry = MaintenanceEntry( final entry = MaintenanceEntry(
id: _uuid.v4(), id: _uuid.v4(),
weaponId: weaponId, weaponId: weaponId,
type: type, type: type,
description: description, description: description,
date: DateTime.now(), date: date ?? DateTime.now(),
roundsSinceLastMaintenance: roundsSinceLast, roundsSinceLastMaintenance: roundsSinceLast,
); );
await _databaseHelper.insertMaintenance(entry); await _databaseHelper.insertMaintenance(entry);
@@ -202,6 +236,16 @@ class SessionRepository {
return await _databaseHelper.getMaintenanceForWeapon(weaponId); return await _databaseHelper.getMaintenanceForWeapon(weaponId);
} }
/// Tout l'historique de maintenance de l'armurerie (export de sauvegarde).
Future<List<MaintenanceEntry>> getAllMaintenance() async {
return await _databaseHelper.getAllMaintenance();
}
/// Enregistre une entrée de maintenance déjà construite (import).
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
await _databaseHelper.insertMaintenance(entry);
}
Future<void> deleteMaintenanceEntry(String id) async { Future<void> deleteMaintenanceEntry(String id) async {
await _databaseHelper.deleteMaintenance(id); await _databaseHelper.deleteMaintenance(id);
} }
+76 -492
View File
@@ -1,8 +1,8 @@
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier). /// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
/// ///
/// Gère le workflow complet d'analyse : chargement d'image, détection de cible, /// Gère le workflow complet d'analyse : chargement d'image, gestion des
/// gestion des impacts (manuels et automatiques), calcul des scores, /// impacts placés manuellement, calcul des scores, analyse de groupement
/// analyse de groupement et sauvegarde des sessions. /// et sauvegarde des sessions.
library; library;
import 'dart:io'; import 'dart:io';
@@ -13,44 +13,35 @@ import '../../data/models/target_analysis.dart';
import '../../data/models/shot.dart'; import '../../data/models/shot.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart'; import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
import '../../services/distortion_correction_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/ai_export_service.dart'; import '../../services/ai_export_service.dart';
enum AnalysisState { initial, loading, success, error } enum AnalysisState { initial, loading, success, error }
class AnalysisProvider extends ChangeNotifier { class AnalysisProvider extends ChangeNotifier {
final TargetDetectionService _detectionService;
final ScoreCalculatorService _scoreCalculatorService; final ScoreCalculatorService _scoreCalculatorService;
final GroupingAnalyzerService _groupingAnalyzerService; final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository; final SessionRepository _sessionRepository;
final DistortionCorrectionService _distortionService;
final OpenCVTargetService _opencvTargetService;
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
AnalysisProvider({ AnalysisProvider({
required TargetDetectionService detectionService,
required ScoreCalculatorService scoreCalculatorService, required ScoreCalculatorService scoreCalculatorService,
required GroupingAnalyzerService groupingAnalyzerService, required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository, required SessionRepository sessionRepository,
DistortionCorrectionService? distortionService, }) : _scoreCalculatorService = scoreCalculatorService,
OpenCVTargetService? opencvTargetService,
}) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService, _groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository, _sessionRepository = sessionRepository;
_distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
AnalysisState _state = AnalysisState.initial; AnalysisState _state = AnalysisState.initial;
String? _errorMessage; String? _errorMessage;
String? _imagePath; String? _imagePath;
TargetType? _targetType; TargetType? _targetType;
// Target detection results // AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
double _cropRotation = 0.0;
// Target calibration
double _targetCenterX = 0.5; double _targetCenterX = 0.5;
double _targetCenterY = 0.5; double _targetCenterY = 0.5;
double _targetRadius = 0.4; double _targetRadius = 0.4;
@@ -68,20 +59,12 @@ class AnalysisProvider extends ChangeNotifier {
// Grouping results // Grouping results
GroupingResult? _groupingResult; GroupingResult? _groupingResult;
// Reference-based detection
List<Shot> _referenceImpacts = [];
ImpactCharacteristics? _learnedCharacteristics;
// Distortion correction
bool _distortionCorrectionEnabled = false;
DistortionParameters? _distortionParams;
String? _correctedImagePath;
// Getters // Getters
AnalysisState get state => _state; AnalysisState get state => _state;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
String? get imagePath => _imagePath; String? get imagePath => _imagePath;
TargetType? get targetType => _targetType; TargetType? get targetType => _targetType;
double get cropRotation => _cropRotation; // Getter pour le Plotting
double get targetCenterX => _targetCenterX; double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY; double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius; double get targetRadius => _targetRadius;
@@ -96,30 +79,18 @@ class AnalysisProvider extends ChangeNotifier {
int get totalScore => _scoreResult?.totalScore ?? 0; int get totalScore => _scoreResult?.totalScore ?? 0;
int get shotCount => _shots.length; int get shotCount => _shots.length;
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
// Distortion correction getters /// Modifie et mémorise la rotation de l'image pour le Plotting
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled; void setCropRotation(double rotation) {
DistortionParameters? get distortionParams => _distortionParams; _cropRotation = rotation;
String? get correctedImagePath => _correctedImagePath; notifyListeners();
bool get hasDistortion => _distortionParams?.needsCorrection ?? false; }
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon) /// Charge l'image et initialise les paramètres de cible par défaut.
String? get displayImagePath => /// Le placement des impacts et la calibration se font ensuite manuellement.
_distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath
: _imagePath;
/// Analyze an image
///
/// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set.
Future<void> analyzeImage( Future<void> analyzeImage(
String imagePath, String imagePath,
TargetType targetType, { TargetType targetType, {
bool autoAnalyze = true,
Offset? manualCenter, Offset? manualCenter,
}) async { }) async {
_state = AnalysisState.loading; _state = AnalysisState.loading;
@@ -137,53 +108,13 @@ class AnalysisProvider extends ChangeNotifier {
_imageAspectRatio = frame.image.width / frame.image.height; _imageAspectRatio = frame.image.width / frame.image.height;
frame.image.dispose(); frame.image.dispose();
if (!autoAnalyze) {
// Just setup default values without running detection
_targetCenterX = manualCenter?.dx ?? 0.5; _targetCenterX = manualCenter?.dx ?? 0.5;
_targetCenterY = manualCenter?.dy ?? 0.5; _targetCenterY = manualCenter?.dy ?? 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04; _targetInnerRadius = 0.04;
// Initialize empty shots list
_shots = []; _shots = [];
_state = AnalysisState.success;
notifyListeners();
return;
}
// Detect target and impacts
final result = _detectionService.detectTarget(imagePath, targetType);
if (!result.success) {
_state = AnalysisState.error;
_errorMessage = result.errorMessage;
notifyListeners();
return;
}
_targetCenterX = result.centerX;
_targetCenterY = result.centerY;
_targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts
_shots = result.impacts.map((impact) {
return Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
analysisId: '', // Will be set when saving
);
}).toList();
// Calculate scores
_recalculateScores();
// Calculate grouping
_recalculateGrouping();
_state = AnalysisState.success; _state = AnalysisState.success;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -204,22 +135,18 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Remove a shot /// Efface tous les impacts en un clic (bouton ↻ de l'écran Synthèse).
void removeShot(String shotId) { /// La calibration (centre, rayon, anneaux) n'est pas touchée.
_shots.removeWhere((shot) => shot.id == shotId); void clearShots() {
_shots.clear();
_recalculateScores(); _recalculateScores();
_recalculateGrouping(); _recalculateGrouping();
notifyListeners(); notifyListeners();
} }
/// Move a shot to a new position /// Remove a shot
void moveShot(String shotId, double newX, double newY) { void removeShot(String shotId) {
final index = _shots.indexWhere((shot) => shot.id == shotId); _shots.removeWhere((shot) => shot.id == shotId);
if (index == -1) return;
final newScore = _calculateShotScore(newX, newY);
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
_recalculateScores(); _recalculateScores();
_recalculateGrouping(); _recalculateGrouping();
notifyListeners(); notifyListeners();
@@ -235,265 +162,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({
int darkThreshold = 80,
int minImpactSize = 20,
int maxImpactSize = 500,
double minCircularity = 0.6,
double minFillRatio = 0.5,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = ImpactDetectionSettings(
darkThreshold: darkThreshold,
minImpactSize: minImpactSize,
maxImpactSize: maxImpactSize,
minCircularity: minCircularity,
minFillRatio: minFillRatio,
);
final detectedImpacts = _detectionService.detectImpactsOnly(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
Future<int> autoDetectImpactsWithOpenCV({
double cannyThreshold1 = 50,
double cannyThreshold2 = 150,
double minDist = 20,
double param1 = 100,
double param2 = 30,
int minRadius = 5,
int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5,
bool useContourDetection = true,
double minCircularity = 0.6,
double minContourArea = 50,
double maxContourArea = 5000,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = OpenCVDetectionSettings(
cannyThreshold1: cannyThreshold1,
cannyThreshold2: cannyThreshold2,
minDist: minDist,
param1: param1,
param2: param2,
minRadius: minRadius,
maxRadius: maxRadius,
blurSize: blurSize,
useContourDetection: useContourDetection,
minCircularity: minCircularity,
minContourArea: minContourArea,
maxContourArea: maxContourArea,
);
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings: settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Detect impacts with OpenCV using reference points
Future<int> detectFromReferencesWithOpenCV({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_referenceImpacts.length < 2) {
return 0;
}
// Convertir les références
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
references,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
_referenceImpacts.add(shot);
notifyListeners();
}
/// Remove a reference impact
void removeReferenceImpact(String shotId) {
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
_learnedCharacteristics = null;
notifyListeners();
}
/// Clear all reference impacts
void clearReferenceImpacts() {
_referenceImpacts.clear();
_learnedCharacteristics = null;
notifyListeners();
}
/// Learn characteristics from reference impacts
bool learnFromReferences() {
if (_imagePath == null || _referenceImpacts.length < 2) return false;
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
_imagePath!,
references,
);
notifyListeners();
return _learnedCharacteristics != null;
}
/// Auto-detect impacts using learned reference characteristics
Future<int> detectFromReferences({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_learnedCharacteristics == null) {
return 0;
}
final detectedImpacts = _detectionService.detectImpactsFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
_learnedCharacteristics!,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Adjust target position /// Adjust target position
void adjustTargetPosition( void adjustTargetPosition(
double centerX, double centerX,
@@ -502,17 +170,20 @@ class AnalysisProvider extends ChangeNotifier {
double radius, { double radius, {
int? ringCount, int? ringCount,
List<double>? ringRadii, List<double>? ringRadii,
double zoomScale = 1.0,
Offset offset = Offset.zero,
}) { }) {
_targetCenterX = centerX; _targetCenterX = (centerX - offset.dx) / zoomScale;
_targetCenterY = centerY; _targetCenterY = (centerY - offset.dy) / zoomScale;
_targetInnerRadius = innerRadius; _targetRadius = radius / zoomScale;
_targetRadius = radius; _targetInnerRadius = innerRadius / zoomScale;
if (ringCount != null) { if (ringCount != null) {
_ringCount = ringCount; _ringCount = ringCount;
} }
if (ringRadii != null) {
// CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement !
_ringRadii = ringRadii; _ringRadii = ringRadii;
}
// Recalculate all shot scores based on new target position // Recalculate all shot scores based on new target position
_shots = _shots.map((shot) { _shots = _shots.map((shot) {
@@ -524,119 +195,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio =
1.0; // The corrected image is always square (side x side)
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
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(); // On prévient quand même si pas de correction
}
}
Future<void> runFullDistortionWorkflow() async {
_state = AnalysisState.loading; // Affiche un spinner sur votre UI
notifyListeners();
try {
calculateDistortion(); // Calcule les paramètres
await applyDistortionCorrection(); // Génère le fichier corrigé
_distortionCorrectionEnabled = true; // Active l'affichage
_state = AnalysisState.success;
} catch (e) {
_errorMessage = "Erreur de rendu : $e";
_state = AnalysisState.error;
} finally {
notifyListeners();
}
}
int _calculateShotScore(double x, double y) { int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) { if (_targetType == TargetType.concentric) {
return _scoreCalculatorService.calculateConcentricScore( return _scoreCalculatorService.calculateConcentricScore(
@@ -680,32 +238,35 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
} }
/// Exporte l'image et le json vers le backend IA /// Exporte l'image et le json vers le backend IA.
Future<bool> exportToAiBackend() async { /// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
Future<bool> exportToAiBackend({
String? sessionId,
int? distance,
String? weapon,
}) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'exporter : image ou type de cible manquant."; _errorMessage = "Impossible d'export : image ou type de cible manquant.";
notifyListeners(); notifyListeners();
return false; return false;
} }
// Identifiant d'analyse temporaire final service = AiExportService();
final analysisId = _shots.isNotEmpty && _shots.first.analysisId.isNotEmpty
? _shots.first.analysisId
: 'analysis_${DateTime.now().millisecondsSinceEpoch}';
final service = AiExportService(); // Local instanciation for simplicity
_state = AnalysisState.loading; _state = AnalysisState.loading;
notifyListeners(); notifyListeners();
final success = await service.exportData( final success = await service.exportData(
imagePath: _imagePath!, imagePath: _imagePath!,
sessionId: 'export', sessionId: sessionId ?? 'export',
targetType: _targetType!, targetType: _targetType!,
targetCenterX: _targetCenterX, targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY, targetCenterY: _targetCenterY,
targetRadius: _targetRadius, targetRadius: _targetRadius,
shots: _shots, shots: _shots,
distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown',
); );
_state = AnalysisState.success; _state = AnalysisState.success;
@@ -716,13 +277,14 @@ class AnalysisProvider extends ChangeNotifier {
return success; return success;
} }
/// Save the session (legacy standalone flow) /// Save the session
Future<TargetAnalysis> saveSession({ Future<TargetAnalysis> saveSession({
String? notes, String? notes,
String? sessionId, String? sessionId,
String? weaponName, String? weaponName,
String? weaponId, String? weaponId,
int? distance, int? distance,
DateTime? date,
}) async { }) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
throw Exception('Cannot save: missing image or target type'); throw Exception('Cannot save: missing image or target type');
@@ -746,8 +308,12 @@ class AnalysisProvider extends ChangeNotifier {
if (sessionId != null && sessionId != 'standalone') { if (sessionId != null && sessionId != 'standalone') {
final existingSession = await _sessionRepository.getSession(sessionId); final existingSession = await _sessionRepository.getSession(sessionId);
if (existingSession != null) { if (existingSession != null) {
await _sessionRepository.addAnalysisToSession(sessionId, targetAnalysis); await _sessionRepository.addAnalysisToSession(
sessionId,
targetAnalysis,
);
} else { } else {
// CORRECTION : Utilise la date injectée plutôt que DateTime.now()
await _sessionRepository.createSession( await _sessionRepository.createSession(
id: sessionId, id: sessionId,
weapon: weaponName ?? 'Inconnue', weapon: weaponName ?? 'Inconnue',
@@ -756,9 +322,11 @@ class AnalysisProvider extends ChangeNotifier {
analyses: [targetAnalysis], analyses: [targetAnalysis],
notes: notes, notes: notes,
distance: distance ?? 25, distance: distance ?? 25,
date: date ?? DateTime.now(),
); );
} }
} else { } else {
// CORRECTION : S'applique aussi au flux alternatif sans ID préalable
await _sessionRepository.createSession( await _sessionRepository.createSession(
weapon: weaponName ?? 'Inconnue', weapon: weaponName ?? 'Inconnue',
weaponId: weaponId, weaponId: weaponId,
@@ -766,6 +334,7 @@ class AnalysisProvider extends ChangeNotifier {
analyses: [targetAnalysis], analyses: [targetAnalysis],
notes: notes, notes: notes,
distance: distance ?? 25, distance: distance ?? 25,
date: date ?? DateTime.now(),
); );
} }
@@ -779,6 +348,7 @@ class AnalysisProvider extends ChangeNotifier {
_errorMessage = null; _errorMessage = null;
_imagePath = null; _imagePath = null;
_targetType = null; _targetType = null;
_cropRotation = 0.0;
_targetCenterX = 0.5; _targetCenterX = 0.5;
_targetCenterY = 0.5; _targetCenterY = 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
@@ -789,11 +359,25 @@ class AnalysisProvider extends ChangeNotifier {
_shots = []; _shots = [];
_scoreResult = null; _scoreResult = null;
_groupingResult = null; _groupingResult = null;
_referenceImpacts = [];
_learnedCharacteristics = null;
_distortionCorrectionEnabled = false;
_distortionParams = null;
_correctedImagePath = null;
notifyListeners(); notifyListeners();
} }
/// Met à jour la position d'un impact de tir en direct (Glisser-Déposer).
void updateShotPosition(String shotId, double newX, double newY) {
final index = _shots.indexWhere((s) => s.id == shotId);
if (index != -1) {
// 1. On calcule dynamiquement le nouveau score de la zone survolée
final newScore = _calculateShotScore(newX, newY);
// 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
// 3. On applique les deux méthodes natives de recalcul de ton application
_recalculateScores();
_recalculateGrouping();
// 4. On demande à l'écran de se redessiner en direct
notifyListeners();
}
}
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
/// Écran d'édition des impacts — PLEIN ÉCRAN dédié au zoom et au placement.
///
/// Cet écran est volontairement minimal : un Scaffold dont le body est
/// directement un InteractiveViewer (sans SingleScrollView ni AspectRatio
/// contraint autour). C'est la configuration la plus fiable pour le pinch :
/// l'InteractiveViewer reçoit les deux doigts sans concurrence avec un
/// scroll vertical ou une transformation parente.
///
/// Interactions :
/// - Tap -> ajoute TOUJOURS un impact, même juste à côté
/// (ou par-dessus) un impact existant. Aucun tap
/// n'ouvre d'édition de score : on peut donc
/// placer un impact au pouce près sans être
/// interrompu par une popup.
/// - Appui long + glisser -> déplace l'impact
///
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
/// AnalysisProvider (passé en ChangeNotifierProvider.value côté appelant).
library;
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/shot.dart';
import 'analysis_provider.dart';
import 'widgets/target_overlay.dart';
class ImpactEditorScreen extends StatefulWidget {
const ImpactEditorScreen({super.key});
@override
State<ImpactEditorScreen> createState() => _ImpactEditorScreenState();
}
class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
final TransformationController _transformationController =
TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
String? _movingShotId;
@override
void initState() {
super.initState();
_transformationController.addListener(_onTransformChanged);
}
@override
void dispose() {
_transformationController.removeListener(_onTransformChanged);
_transformationController.dispose();
super.dispose();
}
void _onTransformChanged() {
final scale = _transformationController.value.getMaxScaleOnAxis();
if (scale != _currentZoomScale) {
setState(() => _currentZoomScale = scale);
}
}
/// Convertit une position globale en coordonnées relatives (0..1) sur l'image.
Offset? _toImageRelative(Offset globalPosition) {
final RenderBox? box =
_imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return null;
final local = box.globalToLocal(globalPosition);
final relX = (local.dx / box.size.width).clamp(0.0, 1.0);
final relY = (local.dy / box.size.height).clamp(0.0, 1.0);
return Offset(relX, relY);
}
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
///
/// Utilisé uniquement par l'appui long (déplacement) : le tap simple, lui,
/// ajoute toujours un impact sans chercher à en sélectionner un.
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
{double tolerance = 0.06}) {
Shot? closest;
double minDistance = double.infinity;
for (final shot in provider.shots) {
final dx = shot.x - rel.dx;
final dy = shot.y - rel.dy;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < minDistance && distance < tolerance) {
minDistance = distance;
closest = shot;
}
}
return closest;
}
@override
Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>();
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: Text('Placement des impacts (${provider.shotCount})'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Retour à la synthèse',
onPressed: () => Navigator.pop(context, false),
),
),
// Corbeille (efface tous les impacts) + bouton bleu flottant VALIDER.
// heroTag distinct sur chaque FAB pour éviter le conflit de Hero.
floatingActionButton: Row(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
heroTag: 'reset_impacts',
onPressed: () => provider.clearShots(),
backgroundColor: Colors.grey.shade800,
tooltip: 'Effacer tous les impacts',
child: const Icon(Icons.delete),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: 'validate_impacts',
onPressed: () => Navigator.pop(context, true),
backgroundColor: AppTheme.primaryColor,
icon: const Icon(Icons.check),
label: const Text('VALIDER'),
),
],
),
body: Column(
children: [
// Bandeau d'aide compact
Container(
width: double.infinity,
color: Colors.white10,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text(
'Tap : ajouter un impact • Appui long : déplacer • Pincer : zoomer',
style: TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
),
),
// Zone image plein écran : InteractiveViewer dans un body nu.
Expanded(
child: InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 12.0,
boundaryMargin: const EdgeInsets.all(80),
panEnabled: _movingShotId == null,
child: Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
// TAP : ajoute un impact, sans exception. Même collé à un
// impact existant, le tap crée le nouvel impact au lieu
// d'ouvrir l'édition du score.
onTapUp: (details) {
if (_movingShotId != null) return;
final rel = _toImageRelative(details.globalPosition);
if (rel == null) return;
provider.addShot(rel.dx, rel.dy);
},
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
onLongPressStart: (details) {
final rel = _toImageRelative(details.globalPosition);
if (rel == null) return;
final hit = _hitTestShot(provider, rel);
if (hit != null) {
setState(() => _movingShotId = hit.id);
}
},
onLongPressMoveUpdate: (details) {
if (_movingShotId == null) return;
// Décalage pour que l'impact reste visible au-dessus du doigt.
final adjusted =
details.globalPosition + const Offset(-25, -35);
final rel = _toImageRelative(adjusted);
if (rel == null) return;
provider.updateShotPosition(
_movingShotId!, rel.dx, rel.dy);
},
onLongPressEnd: (_) {
if (_movingShotId != null) {
setState(() => _movingShotId = null);
}
},
child: Stack(
children: [
Image.file(
File(provider.imagePath!),
key: _imageKey,
fit: BoxFit.contain,
),
Positioned.fill(
child: TargetOverlay(
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
// Aucun onShotTapped : les impacts ne captent plus le
// toucher, tout va au GestureDetector parent qui
// ajoute un impact (y compris pile sur un impact
// existant).
),
),
],
),
),
),
),
),
],
),
);
}
}
@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../services/grouping_analyzer_service.dart'; import '../../../services/grouping_analyzer_service.dart';
class GroupingStats extends StatelessWidget { class GroupingStats extends StatelessWidget {
@@ -43,32 +44,65 @@ class GroupingStats extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const MetricInfoButton(
title: 'Groupement',
explanations: [
MetricExplanation(
'Étalement',
'Distance entre vos deux impacts les plus éloignés, '
'exprimée en % de la largeur de l\'image. Plus c\'est '
'bas, plus le groupement est serré.',
),
MetricExplanation(
'Dispersion',
'Régularité des impacts autour de leur centre commun '
'(écart-type). Plus c\'est bas, plus vos tirs sont '
'réguliers.',
),
MetricExplanation(
'Décalage',
'Direction du centre de votre groupement par rapport au '
'centre de la cible (ex. « Droite » = vos tirs sont '
'globalement décalés vers la droite).',
),
MetricExplanation(
'Étoiles',
'Qualité globale du groupement, de ★ (à améliorer) à '
'★★★★★ (excellent).',
),
],
),
const Spacer(), const Spacer(),
_buildQualityBadge(context), _buildQualityBadge(context),
], ],
), ),
const Divider(), const Divider(),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildStat( Expanded(
child: _buildStat(
context, context,
'Diametre', 'Étalement',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%', '${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten, icon: Icons.straighten,
), ),
_buildStat( ),
Expanded(
child: _buildStat(
context, context,
'Dispersion', 'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%', '${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot, icon: Icons.scatter_plot,
), ),
_buildStat( ),
Expanded(
child: _buildStat(
context, context,
'Decalage', 'Décalage',
offsetDescription, offsetDescription,
icon: Icons.compare_arrows, icon: Icons.compare_arrows,
), ),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value, value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Text( Text(
label, label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
String _getOffsetDescription(double offsetX, double offsetY) { String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) { if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre'; return 'Centré';
} }
String vertical = ''; String vertical = '';
String horizontal = ''; String horizontal = '';
if (offsetY < -0.02) { if (offsetY < -0.02) {
vertical = 'H'; vertical = 'Haut';
} else if (offsetY > 0.02) { } else if (offsetY > 0.02) {
vertical = 'B'; vertical = 'Bas';
} }
if (offsetX < -0.02) { if (offsetX < -0.02) {
horizontal = 'G'; horizontal = 'Gauche';
} else if (offsetX > 0.02) { } else if (offsetX > 0.02) {
horizontal = 'D'; horizontal = 'Droite';
} }
if (vertical.isNotEmpty && horizontal.isNotEmpty) { if (vertical.isNotEmpty && horizontal.isNotEmpty) {
+72 -1
View File
@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../data/models/target_type.dart'; import '../../../data/models/target_type.dart';
import '../../../services/score_calculator_service.dart'; import '../../../services/score_calculator_service.dart';
@@ -16,12 +17,22 @@ class ScoreCard extends StatelessWidget {
final ScoreResult? scoreResult; final ScoreResult? scoreResult;
final TargetType targetType; final TargetType targetType;
/// Score cumulé de la session (cibles déjà validées + cible en cours).
///
/// null hors session : le bandeau de session n'est alors pas affiché.
final int? sessionTotalScore;
/// Nombre de cibles comptabilisées dans [sessionTotalScore].
final int sessionTargetCount;
const ScoreCard({ const ScoreCard({
super.key, super.key,
required this.totalScore, required this.totalScore,
required this.shotCount, required this.shotCount,
this.scoreResult, this.scoreResult,
required this.targetType, required this.targetType,
this.sessionTotalScore,
this.sessionTargetCount = 0,
}); });
@override @override
@@ -44,6 +55,41 @@ class ScoreCard extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(),
if (sessionTotalScore != null) ...[
Flexible(child: _buildSessionBadge(context)),
const SizedBox(width: 4),
],
MetricInfoButton(
title: 'Score',
explanations: [
MetricExplanation(
'Total',
'Somme des points de tous vos impacts sur CETTE cible, '
'sur le maximum possible (nombre d\'impacts × '
'$maxScore points).',
),
const MetricExplanation(
'Session',
'Score cumulé de toutes les cibles de la session en '
'cours, cible affichée comprise.',
),
const MetricExplanation(
'Impacts',
'Nombre de tirs détectés sur la cible.',
),
MetricExplanation(
'Moyenne',
'Points marqués en moyenne par impact, sur $maxScore.',
),
const MetricExplanation(
'Réussite',
'Votre score exprimé en pourcentage du score maximum '
'possible. C\'est une mesure du résultat, pas de la '
'régularité des tirs.',
),
],
),
], ],
), ),
const Divider(), const Divider(),
@@ -67,11 +113,12 @@ class ScoreCard extends StatelessWidget {
shotCount > 0 shotCount > 0
? (totalScore / shotCount).toStringAsFixed(1) ? (totalScore / shotCount).toStringAsFixed(1)
: '-', : '-',
subtitle: '/ $maxScore',
), ),
if (scoreResult != null) if (scoreResult != null)
_buildScoreStat( _buildScoreStat(
context, context,
'Pourcentage', 'Réussite',
'${scoreResult!.percentage.toStringAsFixed(0)}%', '${scoreResult!.percentage.toStringAsFixed(0)}%',
), ),
], ],
@@ -95,6 +142,30 @@ class ScoreCard extends StatelessWidget {
); );
} }
/// Bandeau compact rappelant le score total de la session en cours.
Widget _buildSessionBadge(BuildContext context) {
// Le nombre de cibles n'est rappelé qu'à partir de la deuxième : sur la
// première il n'apporte rien et allonge le bandeau pour rien.
final cibles = sessionTargetCount > 1 ? ' ($sessionTargetCount cibles)' : '';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Session : $sessionTotalScore pts$cibles',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
);
}
Widget _buildScoreStat( Widget _buildScoreStat(
BuildContext context, BuildContext context,
String label, String label,
@@ -4,9 +4,8 @@
/// Les anneaux sont répartis proportionnellement. /// Les anneaux sont répartis proportionnellement.
library; library;
import 'dart:math' as math; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart'; import '../../../data/models/target_type.dart';
class TargetCalibration extends StatefulWidget { class TargetCalibration extends StatefulWidget {
@@ -44,6 +43,15 @@ class TargetCalibration extends StatefulWidget {
} }
class TargetCalibrationState extends State<TargetCalibration> { class TargetCalibrationState extends State<TargetCalibration> {
/// Bornes du rayon global (mesurées pour laisser de la liberté sans
/// débordement incontrôlé).
static const double minRadius = 0.3;
static const double maxRadius = 0.95;
/// Bornes de l'espacement : max bridé à 0.70 pour confiner le dernier cercle.
static const double minSpacing = 0.01;
static const double maxSpacing = 0.70;
late double _centerX; late double _centerX;
late double _centerY; late double _centerY;
late double _radius; late double _radius;
@@ -51,10 +59,19 @@ class TargetCalibrationState extends State<TargetCalibration> {
late int _ringCount; late int _ringCount;
late List<double> _ringRadii; late List<double> _ringRadii;
// Variable dédiée pour piloter la jauge orange sans conflit
late double _currentEspacementRatio;
bool _isDraggingCenter = false; bool _isDraggingCenter = false;
bool _isDraggingRadius = false; bool _isDraggingRadius = false;
bool _isDraggingInnerRadius = false; bool _isDraggingInnerRadius = false;
bool _showEspacement = false;
double _baseRadiusBeforeScale = 1.0;
// SAUVEGARDE CRITIQUE : Pour mémoriser la structure d'origine de l'IA
List<double>? _originalRingRadii;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -63,19 +80,37 @@ class TargetCalibrationState extends State<TargetCalibration> {
_radius = widget.initialRadius; _radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius; _innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
// On mémorise la configuration d'usine de l'image
if (widget.initialRingRadii != null) {
_originalRingRadii = List.from(widget.initialRingRadii!);
}
// Initialisation du ratio par défaut
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
_initRingRadii(); _initRingRadii();
} }
/// Fige et propage la calibration courante vers le provider.
///
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
/// correspond exactement au dernier réglage validé, sans dépendre d'un
/// éventuel rebuild intermédiaire.
void commitCalibration() {
_notifyChange();
}
void _initRingRadii({bool forceRecalculate = false}) { void _initRingRadii({bool forceRecalculate = false}) {
if (!forceRecalculate && widget.initialRingRadii != null && // CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
widget.initialRingRadii!.length == _ringCount) { if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
_ringRadii = List.from(_originalRingRadii!);
} else if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!); _ringRadii = List.from(widget.initialRingRadii!);
} else { } else {
// Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) { _ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0; if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius; return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
}); });
} }
} }
@@ -85,12 +120,10 @@ class TargetCalibrationState extends State<TargetCalibration> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
bool shouldReinit = false; bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX && if (widget.initialCenterX != oldWidget.initialCenterX && !_isDraggingCenter) {
!_isDraggingCenter) {
_centerX = widget.initialCenterX; _centerX = widget.initialCenterX;
} }
if (widget.initialCenterY != oldWidget.initialCenterY && if (widget.initialCenterY != oldWidget.initialCenterY && !_isDraggingCenter) {
!_isDraggingCenter) {
_centerY = widget.initialCenterY; _centerY = widget.initialCenterY;
} }
if (widget.initialRingCount != oldWidget.initialRingCount) { if (widget.initialRingCount != oldWidget.initialRingCount) {
@@ -101,12 +134,20 @@ class TargetCalibrationState extends State<TargetCalibration> {
_radius = widget.initialRadius; _radius = widget.initialRadius;
shouldReinit = true; shouldReinit = true;
} }
if (widget.initialInnerRadius != oldWidget.initialInnerRadius && if (widget.initialInnerRadius != oldWidget.initialInnerRadius && !_isDraggingInnerRadius) {
!_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius; _innerRadius = widget.initialInnerRadius;
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
shouldReinit = true; shouldReinit = true;
} }
if (widget.initialRingRadii != oldWidget.initialRingRadii) { // On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
// réellement d'une nouvelle détection (ils diffèrent de notre état courant).
// Sinon il s'agit de l'écho de notre propre _notifyChange (aller-retour via
// le provider) : le clobber effacerait le profil d'origine et casserait le
// bouton de réinitialisation de l'espacement.
if (widget.initialRingRadii != oldWidget.initialRingRadii &&
widget.initialRingRadii != null &&
!listEquals(widget.initialRingRadii, _ringRadii)) {
_originalRingRadii = List.from(widget.initialRingRadii!);
shouldReinit = true; shouldReinit = true;
} }
@@ -121,12 +162,23 @@ class TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) { builder: (context, constraints) {
final size = constraints.biggest; final size = constraints.biggest;
return Stack( // Les réglages (taille / espacement) sont rendus par l'écran hôte
children: [ // AU-DESSUS de l'image : rien ne vient masquer la cible ici.
GestureDetector( return GestureDetector(
onPanStart: (details) => _onPanStart(details, size), onScaleStart: (details) {
onPanUpdate: (details) => _onPanUpdate(details, size), _baseRadiusBeforeScale = _radius;
onPanEnd: (_) => _onPanEnd(), final tapX = details.localFocalPoint.dx / size.width;
final tapY = details.localFocalPoint.dy / size.height;
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
setState(() {
_isDraggingCenter = true;
});
}
},
onScaleUpdate: (details) => _onScaleUpdate(details, size),
onScaleEnd: (_) => _onScaleEnd(),
child: CustomPaint( child: CustomPaint(
size: size, size: size,
painter: _CalibrationPainter( painter: _CalibrationPainter(
@@ -138,91 +190,88 @@ class TargetCalibrationState extends State<TargetCalibration> {
ringRadii: _ringRadii, ringRadii: _ringRadii,
targetType: widget.targetType, targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter, isDraggingCenter: _isDraggingCenter,
isDraggingRadius: false, // Plus de drag direct du rayon isDraggingRadius: false,
isDraggingInnerRadius: false, isDraggingInnerRadius: false,
), ),
), ),
),
// Slider pour le rayon (agrandissement)
Positioned(
top: 10,
left: 40,
right: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
Expanded(
child: Slider(
value: _radius.clamp(0.05, 3.0),
min: 0.05,
max: 3.0,
activeColor: AppTheme.primaryColor,
onChanged: (value) {
setState(() {
_radius = value;
// On force le recalcul car on change la taille
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
],
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.compress, color: Colors.white, size: 16),
Expanded(
child: Slider(
value: (_innerRadius / _radius).clamp(0.01, 0.9),
min: 0.01,
max: 0.9,
activeColor: Colors.orange,
onChanged: (value) {
setState(() {
// On ajuste l'innerRadius comme un ratio du radius global
_innerRadius = _radius * value;
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.expand, color: Colors.white, size: 16),
],
),
),
],
),
),
],
); );
}, },
); );
} }
// ---------------------------------------------------------------------------
// API publique pilotée par l'écran hôte (panneau de réglages hors de l'image)
// ---------------------------------------------------------------------------
/// Espacement courant (rayon du premier anneau, en fraction du rayon global).
double get spacingRatio => _currentEspacementRatio;
/// Mode d'espacement manuel actif ou non.
bool get isSpacingModeEnabled => _showEspacement;
/// Applique une nouvelle taille globale (rayon normalisé).
void setRadius(double value) {
setState(() {
_radius = value.clamp(minRadius, maxRadius);
_innerRadius = _radius * _currentEspacementRatio;
// Si le mode avancé n'est pas coché, on applique la taille pure sans
// détruire le ratio d'origine.
_initRingRadii(forceRecalculate: _showEspacement);
});
_notifyChange();
}
/// Agrandit / réduit la cible de [deltaPixels] pixels.
///
/// La conversion utilise la plus petite dimension de [size], exactement comme
/// le painter, pour que le pas corresponde bien à un pixel à l'écran.
void adjustRadiusByPixels(double deltaPixels, Size size) {
final minDim = size.width < size.height ? size.width : size.height;
if (minDim <= 0) return;
setRadius(_radius + deltaPixels / minDim);
}
/// Active ou non le réglage manuel de l'espacement.
///
/// À la désactivation, on restaure la configuration d'usine.
void setSpacingMode(bool enabled) {
setState(() {
_showEspacement = enabled;
if (!enabled) {
_initRingRadii(forceRecalculate: false);
}
});
_notifyChange();
}
/// Applique un nouvel espacement entre les anneaux.
void setSpacingRatio(double value) {
setState(() {
_currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
}
/// Restaure le vrai profil d'usine détecté sur l'image.
void resetSpacing() {
setState(() {
if (_originalRingRadii != null) {
_initRingRadii(forceRecalculate: false);
if (_ringRadii.isNotEmpty) {
_currentEspacementRatio = _ringRadii.first;
_innerRadius = _radius * _currentEspacementRatio;
}
} else {
_currentEspacementRatio = 0.1;
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: true);
}
});
_notifyChange();
}
Widget buildDirectionalControls(BuildContext context, Size size) { Widget buildDirectionalControls(BuildContext context, Size size) {
return Container( return Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -231,42 +280,55 @@ class TargetCalibrationState extends State<TargetCalibration> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10), border: Border.all(color: Colors.white10),
), ),
child: Column( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildDirectionButton( // Réduit la cible d'un pixel.
Icons.keyboard_arrow_up, _buildSizeButton('', () => adjustRadiusByPixels(-1, size)),
() => _moveCenterByPixels(0, -1, size), const SizedBox(width: 12),
), Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildDirectionButton( _buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
Icons.keyboard_arrow_left,
() => _moveCenterByPixels(-1, 0, size),
),
const SizedBox(width: 40), const SizedBox(width: 40),
_buildDirectionButton( _buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
Icons.keyboard_arrow_right,
() => _moveCenterByPixels(1, 0, size),
),
], ],
), ),
_buildDirectionButton( _buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
Icons.keyboard_arrow_down,
() => _moveCenterByPixels(0, 1, size),
),
], ],
), ),
const SizedBox(width: 12),
// Agrandit la cible d'un pixel.
_buildSizeButton('+', () => adjustRadiusByPixels(1, size)),
],
),
);
}
/// Bouton / + de part et d'autre de la croix : ajuste la TAILLE de la cible.
Widget _buildSizeButton(String sign, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
child: IconButton(
icon: Text(
sign,
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
tooltip: sign == '+' ? 'Agrandir la cible' : 'Réduire la cible',
),
); );
} }
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) { Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.all(2), margin: const EdgeInsets.all(2),
child: IconButton( child: IconButton(
icon: Icon(icon, color: Colors.white, size: 28), icon: Icon(icon, color: Colors.white, size: 28),
@@ -296,36 +358,23 @@ class TargetCalibrationState extends State<TargetCalibration> {
_notifyChange(); _notifyChange();
} }
void _onPanStart(DragStartDetails details, Size size) { void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
final tapX = details.localPosition.dx / size.width;
final tapY = details.localPosition.dy / size.height;
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
setState(() { setState(() {
_isDraggingCenter = true; if (details.pointerCount == 2) {
}); _radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius);
} _innerRadius = _radius * _currentEspacementRatio;
} _initRingRadii(forceRecalculate: _showEspacement);
} else if (_isDraggingCenter) {
void _onPanUpdate(DragUpdateDetails details, Size size) { final deltaX = details.focalPointDelta.dx / size.width;
final deltaX = details.delta.dx / size.width; final deltaY = details.focalPointDelta.dy / size.height;
final deltaY = details.delta.dy / size.height;
final minDim = math.min(size.width, size.height);
setState(() {
if (_isDraggingCenter) {
// Move center
_centerX = _centerX + deltaX; _centerX = _centerX + deltaX;
_centerY = _centerY + deltaY; _centerY = _centerY + deltaY;
} }
}); });
_notifyChange(); _notifyChange();
} }
void _onPanEnd() { void _onScaleEnd() {
setState(() { setState(() {
_isDraggingCenter = false; _isDraggingCenter = false;
_isDraggingRadius = false; _isDraggingRadius = false;
@@ -370,7 +419,6 @@ class _CalibrationPainter extends CustomPainter {
final centerPx = Offset(centerX * size.width, centerY * size.height); final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height; final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim; final baseRadiusPx = radius * minDim;
final innerRadiusPx = innerRadius * minDim;
if (targetType == TargetType.concentric) { if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx); _drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
@@ -378,37 +426,17 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx); _drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
} }
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) { if (isDraggingCenter) {
final crosshairLinePaint = Paint() final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
..color = Colors.red.withValues(alpha: 0.5) canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
..strokeWidth = 1; canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
canvas.drawLine(
Offset(0, centerPx.dy),
Offset(size.width, centerPx.dy),
crosshairLinePaint,
);
canvas.drawLine(
Offset(centerPx.dx, 0),
Offset(centerPx.dx, size.height),
crosshairLinePaint,
);
} }
// Draw center handle
_drawCenterHandle(canvas, centerPx); _drawCenterHandle(canvas, centerPx);
// Draw instructions
_drawInstructions(canvas, size); _drawInstructions(canvas, size);
} }
void _drawConcentricZones( void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones
List<Color> zoneColors = []; List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final ratio = i / ringCount; final ratio = i / ringCount;
@@ -431,11 +459,8 @@ class _CalibrationPainter extends CustomPainter {
..strokeWidth = 1 ..strokeWidth = 1
..color = Colors.red.withValues(alpha: 0.8); ..color = Colors.red.withValues(alpha: 0.8);
// Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) { for (int i = ringCount - 1; i >= 0; i--) {
final ringRadius = ringRadii.length > i final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
? ringRadii[i]
: (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius; final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i]; zonePaint.color = zoneColors[i];
@@ -443,22 +468,14 @@ class _CalibrationPainter extends CustomPainter {
canvas.drawCircle(center, zoneRadius, strokePaint); canvas.drawCircle(center, zoneRadius, strokePaint);
} }
// Draw zone labels (only if within visible area)
final textPainter = TextPainter(textDirection: TextDirection.ltr); final textPainter = TextPainter(textDirection: TextDirection.ltr);
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
? ringRadii[i] final textSpanRatio = i > 0 ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) : 0.0;
: (i + 1) / ringCount; final zoneRadius = baseRadius * (ringRadius + textSpanRatio) / 2;
final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0;
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
// Score: center = 10, decrement by 1 for each ring
final score = 10 - i; final score = 10 - i;
// Only draw label if it's within the visible area
final labelX = center.dx + zoneRadius; final labelX = center.dx + zoneRadius;
if (labelX < 0 || labelX > size.width) continue; if (labelX < 0 || labelX > size.width) continue;
@@ -473,102 +490,44 @@ class _CalibrationPainter extends CustomPainter {
); );
textPainter.layout(); textPainter.layout();
// Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2; final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) { if (labelY >= 0 && labelY <= size.height) {
textPainter.paint( textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
canvas,
Offset(labelX - textPainter.width / 2, labelY),
);
} }
} }
} }
void _drawSilhouetteZones( void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
Canvas canvas, final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now)
final silhouetteWidth = radius * 0.8; final silhouetteWidth = radius * 0.8;
final silhouetteHeight = radius * 2; final silhouetteHeight = radius * 2;
paint.color = Colors.green.withValues(alpha: 0.5); paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect( canvas.drawRect(Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), paint);
Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint,
);
} }
void _drawCenterHandle(Canvas canvas, Offset center) { void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle final outerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.stroke..strokeWidth = 3;
final outerPaint = Paint()
..color = isDraggingCenter ? Colors.redAccent : Colors.red
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint); canvas.drawCircle(center, 15, outerPaint);
// Inner dot final innerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.fill;
final innerPaint = Paint()
..color = isDraggingCenter ? Colors.redAccent : Colors.red
..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint); canvas.drawCircle(center, 5, innerPaint);
// Crosshair final crossPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..strokeWidth = 2;
final crossPaint = Paint() canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
..color = isDraggingCenter ? Colors.redAccent : Colors.red canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
..strokeWidth = 2; canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine( canvas.drawLine(Offset(center.dx, center.dy + 4), Offset(center.dx, center.dy + 20), crossPaint);
Offset(center.dx - 20, center.dy),
Offset(center.dx - 8, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx + 8, center.dy),
Offset(center.dx + 20, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy - 20),
Offset(center.dx, center.dy - 8),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy + 8),
Offset(center.dx, center.dy + 20),
crossPaint,
);
} }
void _drawInstructions(Canvas canvas, Size size) { void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon'; const instruction = 'Deplacez le centre ou ajustez le rayon';
final textPainter = TextPainter( final textPainter = TextPainter(
text: TextSpan( text: TextSpan(text: instruction, style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 12, backgroundColor: Colors.black54)),
text: instruction,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
backgroundColor: Colors.black54,
),
),
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
); );
textPainter.layout(); textPainter.layout();
textPainter.paint( textPainter.paint(canvas, Offset((size.width - textPainter.width) / 2, size.height - 30));
canvas,
Offset((size.width - textPainter.width) / 2, size.height - 30),
);
} }
@override @override
@@ -1,8 +1,9 @@
/// Overlay visuel de la cible. /// Overlay visuel de la cible.
/// ///
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement /// Dessine les anneaux de la cible, les impacts et le cercle de groupement.
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout /// Gère uniquement la SÉLECTION d'impacts existants (tap sur un impact).
/// d'impacts et la sélection d'impacts existants. /// L'AJOUT d'un impact est délégué à l'écran parent pour éviter tout conflit
/// de gestes avec le zoom/pan de l'InteractiveViewer.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -19,11 +20,9 @@ class TargetOverlay extends StatelessWidget {
final int ringCount; final int ringCount;
final List<double>? ringRadii; final List<double>? ringRadii;
final void Function(Shot shot)? onShotTapped; final void Function(Shot shot)? onShotTapped;
final void Function(double x, double y)? onAddShot;
final double? groupingCenterX; final double? groupingCenterX;
final double? groupingCenterY; final double? groupingCenterY;
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings; final bool showRings;
@@ -37,29 +36,29 @@ class TargetOverlay extends StatelessWidget {
this.ringCount = 10, this.ringCount = 10,
this.ringRadii, this.ringRadii,
this.onShotTapped, this.onShotTapped,
this.onAddShot,
this.groupingCenterX, this.groupingCenterX,
this.groupingCenterY, this.groupingCenterY,
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false, this.showRings = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( // IMPORTANT : plus de GestureDetector global ici.
builder: (context, constraints) { // L'ancien GestureDetector (onTapUp couvrant toute la surface, en
return GestureDetector( // HitTestBehavior.translucent) volait les pointeurs au pinch de
behavior: HitTestBehavior.translucent, // l'InteractiveViewer parent et rendait le zoom capricieux.
onTapUp: (details) { //
if (onAddShot != null) { // Désormais :
// Utiliser les constraints pour un calcul précis // - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
final relX = details.localPosition.dx / constraints.maxWidth; // - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
final relY = details.localPosition.dy / constraints.maxHeight; // zones de tap localisées (deferToChild) placées sur chaque impact —
onAddShot!(relX, relY); // et UNIQUEMENT si [onShotTapped] est fourni. Sans callback, aucune zone
} // de tap n'est créée : un tap pile sur un impact traverse jusqu'au parent
}, // au lieu d'être absorbé dans le vide.
return IgnorePointer(
ignoring: false,
child: CustomPaint( child: CustomPaint(
painter: _TargetOverlayPainter( painter: _TargetOverlayPainter(
shots: shots, shots: shots,
@@ -72,52 +71,47 @@ class TargetOverlay extends StatelessWidget {
groupingCenterX: groupingCenterX, groupingCenterX: groupingCenterX,
groupingCenterY: groupingCenterY, groupingCenterY: groupingCenterY,
groupingDiameter: groupingDiameter, groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts,
zoomScale: zoomScale, zoomScale: zoomScale,
showRings: showRings, showRings: showRings,
), ),
child: Stack(
children: shots.map((shot) {
return Positioned(
left: 0,
top: 0,
right: 0,
bottom: 0,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, innerConstraints) { builder: (context, constraints) {
final x = shot.x * innerConstraints.maxWidth; final onTapped = onShotTapped;
final y = shot.y * innerConstraints.maxHeight; if (onTapped == null) return const SizedBox.expand();
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran) return Stack(
children: shots.map((shot) {
final x = shot.x * constraints.maxWidth;
final y = shot.y * constraints.maxHeight;
// Zone de tap qui reste constante à l'écran malgré le zoom.
final tapSize = 30 / zoomScale; final tapSize = 30 / zoomScale;
final halfTapSize = tapSize / 2; final halfTapSize = tapSize / 2;
return Stack( return Positioned(
children: [
Positioned(
left: x - halfTapSize, left: x - halfTapSize,
top: y - halfTapSize, top: y - halfTapSize,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, // deferToChild : ne capte le toucher QUE sur la zone du
onTap: () => onShotTapped?.call(shot), // Container (un cercle opaque au hit-test), pas ailleurs.
// Le reste de la surface reste donc disponible pour le
// pinch/pan de l'InteractiveViewer.
behavior: HitTestBehavior.deferToChild,
onTap: () => onTapped(shot),
child: Container( child: Container(
width: tapSize, width: tapSize,
height: tapSize, height: tapSize,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.transparent, // Opaque pour le hit-test (couleur transparente visuellement
// mais non nulle), pour que le tap soit bien capté ici.
color: Color(0x01000000),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
), ),
),
],
);
},
),
); );
}).toList(), }).toList(),
),
),
); );
}, },
),
),
); );
} }
} }
@@ -133,7 +127,6 @@ class _TargetOverlayPainter extends CustomPainter {
final double? groupingCenterX; final double? groupingCenterX;
final double? groupingCenterY; final double? groupingCenterY;
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings; final bool showRings;
@@ -148,7 +141,6 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingCenterX, this.groupingCenterX,
this.groupingCenterY, this.groupingCenterY,
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false, this.showRings = false,
}); });
@@ -169,13 +161,6 @@ class _TargetOverlayPainter extends CustomPainter {
for (final shot in shots) { for (final shot in shots) {
_drawImpact(canvas, size, shot); _drawImpact(canvas, size, shot);
} }
// Draw reference impacts (with different color)
if (referenceImpacts != null) {
for (final ref in referenceImpacts!) {
_drawReferenceImpact(canvas, size, ref);
}
}
} }
void _drawTargetCenter(Canvas canvas, Size size) { void _drawTargetCenter(Canvas canvas, Size size) {
@@ -292,16 +277,17 @@ class _TargetOverlayPainter extends CustomPainter {
final strokeWidth = 3 / zoomScale; final strokeWidth = 3 / zoomScale;
final fontSize = 10 / zoomScale; final fontSize = 10 / zoomScale;
// Draw outer circle (white outline for visibility) // Draw outer circle (white outline for visibility) — gardé OPAQUE pour
// bien repérer le centre même quand le remplissage est transparent.
final outlinePaint = Paint() final outlinePaint = Paint()
..color = AppTheme.impactOutlineColor ..color = AppTheme.impactOutlineColor
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = strokeWidth; ..strokeWidth = strokeWidth;
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint); canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
// Draw impact marker // Draw impact marker — TRANSPARENCE 30% pour voir l'impact réel derrière
final impactPaint = Paint() final impactPaint = Paint()
..color = AppTheme.impactColor ..color = AppTheme.impactColor.withValues(alpha: 0.3)
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint); canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
@@ -324,48 +310,6 @@ class _TargetOverlayPainter extends CustomPainter {
); );
} }
void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) {
final x = ref.x * size.width;
final y = ref.y * size.height;
// Tailles fixes divisées par le zoom pour rester constantes à l'écran
final outerRadius = 12 / zoomScale;
final innerRadius = 10 / zoomScale;
final strokeWidth = 3 / zoomScale;
final fontSize = 12 / zoomScale;
// Draw outer circle (white outline for visibility)
final outlinePaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
// Draw reference marker (purple)
final refPaint = Paint()
..color = Colors.deepPurple
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), innerRadius, refPaint);
// Draw "R" to indicate reference
final textPainter = TextPainter(
text: TextSpan(
text: 'R',
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
);
}
@override @override
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) { bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
return shots != oldDelegate.shots || return shots != oldDelegate.shots ||
@@ -377,7 +321,6 @@ class _TargetOverlayPainter extends CustomPainter {
groupingCenterX != oldDelegate.groupingCenterX || groupingCenterX != oldDelegate.groupingCenterX ||
groupingCenterY != oldDelegate.groupingCenterY || groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter || groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts ||
zoomScale != oldDelegate.zoomScale || zoomScale != oldDelegate.zoomScale ||
showRings != oldDelegate.showRings; showRings != oldDelegate.showRings;
} }
File diff suppressed because it is too large Load Diff
+318 -83
View File
@@ -27,6 +27,11 @@ class CropScreen extends StatefulWidget {
} }
class _CropScreenState extends State<CropScreen> { class _CropScreenState extends State<CropScreen> {
// Bornes et pas de la rotation, partagés par la jauge et les boutons / +.
static const double _minRotation = -15.0;
static const double _maxRotation = 15.0;
static const double _rotationStep = 0.1;
final ImageCropService _cropService = ImageCropService(); final ImageCropService _cropService = ImageCropService();
// États de transformation // États de transformation
@@ -36,6 +41,9 @@ class _CropScreenState extends State<CropScreen> {
Offset _startOffset = Offset.zero; Offset _startOffset = Offset.zero;
Offset _startFocalPoint = Offset.zero; Offset _startFocalPoint = Offset.zero;
// PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés
double _rotation = 0.0;
bool _isLoading = false; bool _isLoading = false;
bool _imageLoaded = false; bool _imageLoaded = false;
Size? _imageSize; Size? _imageSize;
@@ -78,15 +86,64 @@ class _CropScreenState extends State<CropScreen> {
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
actions: [
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Réinitialiser l\'orientation',
onPressed: () {
setState(() {
_rotation = 0.0;
_scale = 1.0;
_offset = Offset.zero;
_initializeImagePosition();
});
},
),
],
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8))) ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column( : Column(
children: [ children: [
// Zone interactive de crop // TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(
'Zoomer au maximum puis aligner votre cible',
style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 4),
Text(
'Glissez à un doigt pour déplacer, pincez pour zoomer',
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
),
],
),
),
// Zone interactive de crop, CARRÉE. La photo la remplit entièrement
// (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre
// est récupérable en déplaçant/zoomant. La sortie d'analyse reste
// carrée, donc la cible n'est pas déformée.
Expanded( Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Container( child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10), border: Border.all(color: Colors.white10),
@@ -97,27 +154,66 @@ class _CropScreenState extends State<CropScreen> {
), ),
), ),
), ),
),
),
),
// Bouton d'aide / Modifier const SizedBox(height: 8),
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel,
// encadrée par les deux boutons de rotation fine.
_buildDirectionalPad(),
const SizedBox(height: 16),
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Icon(Icons.center_focus_strong, color: Colors.redAccent, size: 20), const Text(
const SizedBox(width: 8), 'Rotation de l\'image',
style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600),
),
Text( Text(
'Alignez le centre de la cible sur la croix', '${_rotation.toStringAsFixed(1)}°',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500), style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold),
), ),
], ],
), ),
const SizedBox(height: 4), Row(
Text( children: [
'Zoomez pour plus de précision', // Les icônes de sens de rotation ont rejoint les boutons / +
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12), // de la croix directionnelle.
Expanded(
child: Slider(
value: _rotation,
min: _minRotation,
max: _maxRotation,
divisions: 300,
label: '${_rotation.toStringAsFixed(1)}°',
activeColor: const Color(0xFF1A73E8),
inactiveColor: Colors.white12,
onChanged: (double value) {
setState(() {
_rotation = value;
});
},
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
onPressed: () {
setState(() {
_rotation = 0.0;
});
},
),
],
), ),
], ],
), ),
@@ -125,7 +221,7 @@ class _CropScreenState extends State<CropScreen> {
// Boutons du bas // Boutons du bas
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30), padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@@ -149,7 +245,7 @@ class _CropScreenState extends State<CropScreen> {
padding: const EdgeInsets.symmetric(vertical: 15), padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
), ),
child: const Text('Analyser', style: TextStyle(fontWeight: FontWeight.bold)), child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
), ),
), ),
], ],
@@ -165,10 +261,11 @@ class _CropScreenState extends State<CropScreen> {
builder: (context, constraints) { builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight); _viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// Le carré de crop occupe presque tout l'espace disponible // La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95; // 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) { if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
_initializeImagePosition(); _initializeImagePosition();
} }
@@ -177,22 +274,73 @@ class _CropScreenState extends State<CropScreen> {
onScaleUpdate: _onScaleUpdate, onScaleUpdate: _onScaleUpdate,
child: Stack( child: Stack(
children: [ children: [
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
Positioned.fill( Positioned.fill(
child: Center( child: Center(
child: Transform( child: Transform(
transform: Matrix4.identity() transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0) ..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale), ..scaleByDouble(_scale, _scale, _scale, 1.0)
..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center, alignment: Alignment.center,
child: Image.file( child: Image.file(
File(widget.imagePath), File(widget.imagePath),
fit: BoxFit.contain, fit: BoxFit.cover,
width: _viewportSize.width, width: _viewportSize.width,
height: _viewportSize.height, height: _viewportSize.height,
), ),
), ),
), ),
), ),
// 2. Les lignes vertes de visée prolongées
Positioned.fill(
child: IgnorePointer(
child: Stack(
children: [
Center(
child: Container(
width: double.infinity,
height: 1.5,
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
],
),
),
),
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
Positioned.fill(
child: IgnorePointer(
child: ColorFiltered(
colorFilter: ColorFilter.mode(
const Color(0xFF101214),
BlendMode.srcOut,
),
child: Stack(
children: [
Center(
child: Container(
width: _cropSize,
height: _cropSize,
color: Colors.black,
),
),
],
),
),
),
),
// 4. Ton overlay avec les coins blancs
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
child: CropOverlay(cropSize: _cropSize, showGrid: false), child: CropOverlay(cropSize: _cropSize, showGrid: false),
@@ -208,28 +356,126 @@ class _CropScreenState extends State<CropScreen> {
void _initializeImagePosition() { void _initializeImagePosition() {
if (_imageSize == null) return; if (_imageSize == null) return;
final imageAspect = _imageSize!.width / _imageSize!.height; // 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
final viewportAspect = _viewportSize.width / _viewportSize.height; _scale = widget.initialScale ?? 1.0;
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);
_scale = widget.initialScale ?? (_cropSize / minDisplayDim);
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0; if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
if (widget.initialOffset != null) { if (widget.initialOffset != null) {
_offset = widget.initialOffset!; _offset = widget.initialOffset!;
} else {
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
} }
} }
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
// Les boutons « » et « + » de part et d'autre pivotent l'image de 0,1°.
Widget _buildDirectionalPad() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildRotationButton(
icon: Icons.rotate_left,
sign: '',
iconFirst: true,
tooltip: 'Pivoter vers la gauche',
onPressed: () => _rotateBy(-_rotationStep),
),
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),
_buildRotationButton(
icon: Icons.rotate_right,
sign: '+',
iconFirst: false,
tooltip: 'Pivoter vers la droite',
onPressed: () => _rotateBy(_rotationStep),
),
],
);
}
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),
),
);
}
// Bouton de rotation : l'icône de sens est accolée au signe / +.
Widget _buildRotationButton({
required IconData icon,
required String sign,
required bool iconFirst,
required String tooltip,
required VoidCallback onPressed,
}) {
final content = [
Icon(icon, color: Colors.white70, size: 20),
const SizedBox(width: 4),
Text(
sign,
style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
),
];
return Tooltip(
message: tooltip,
child: Material(
color: Colors.black54,
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: iconFirst ? content : content.reversed.toList(),
),
),
),
),
);
}
void _nudge(double dx, double dy) {
setState(() {
_offset = _offset + Offset(dx, dy);
});
}
/// Pivote l'image de [delta] degrés, dans les mêmes bornes que la jauge.
///
/// La valeur est arrondie au dixième pour rester calée sur les crans de la
/// jauge (300 divisions sur 30°) et sur l'affichage.
void _rotateBy(double delta) {
setState(() {
final value = (_rotation + delta).clamp(_minRotation, _maxRotation);
_rotation = (value * 10).roundToDouble() / 10;
});
}
void _onScaleStart(ScaleStartDetails details) { void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale; _baseScale = _scale;
_startFocalPoint = details.focalPoint; _startFocalPoint = details.focalPoint;
@@ -247,21 +493,49 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async { Future<void> _onCropConfirm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final cropRect = _calculateCropRect(); // Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
// On calcule le centre relatif basé sur le centrage utilisateur // utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
final targetCenterX = cropRect.x + cropRect.width / 2; // le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
final targetCenterY = cropRect.y + cropRect.height / 2; final double displayPerSourcePx = math.max(
_viewportSize.width / _imageSize!.width,
_viewportSize.height / _imageSize!.height,
);
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
// sont remplis en noir → la position choisie est respectée à l'identique.
final croppedImagePath = await _cropService.cropViewport(
sourcePath: widget.imagePath,
offsetDx: _offset.dx,
offsetDy: _offset.dy,
displayPerSourcePx: displayPerSourcePx,
cropSizeDisplay: _cropSize,
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
// il n'agrandit pas le rendu de sortie.
zoomScale: _scale,
rotationDegrees: _rotation,
);
const targetCenterX = 0.5;
const targetCenterY = 0.5;
if (!mounted) return; if (!mounted) return;
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AnalysisScreen( builder: (_) => AnalysisScreen(
imagePath: widget.imagePath, imagePath: croppedImagePath,
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
originalImagePath: widget.imagePath,
targetType: widget.targetType, targetType: widget.targetType,
targetCenter: Offset(targetCenterX, targetCenterY), initialCenterX: targetCenterX,
cropScale: _scale, initialCenterY: targetCenterY,
cropOffset: _offset, cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
), ),
), ),
); );
@@ -269,49 +543,10 @@ class _CropScreenState extends State<CropScreen> {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
); );
} }
} }
} }
CropRect _calculateCropRect() {
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale;
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2;
final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2;
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropWidth = _cropSize / scaledWidth;
final relCropHeight = _cropSize / scaledHeight;
return CropRect(
x: relCropLeft,
y: relCropTop,
width: relCropWidth,
height: relCropHeight,
);
}
} }
+22 -17
View File
@@ -68,7 +68,7 @@ class _CropOverlayPainter extends CustomPainter {
_drawGrid(canvas, cropRect); _drawGrid(canvas, cropRect);
} }
// Dessiner le point central (croix) // Dessiner le point central (croix interne vert fluo)
_drawCenterPoint(canvas, cropRect); _drawCenterPoint(canvas, cropRect);
} }
@@ -165,35 +165,40 @@ class _CropOverlayPainter extends CustomPainter {
} }
void _drawCenterPoint(Canvas canvas, Rect rect) { void _drawCenterPoint(Canvas canvas, Rect rect) {
// Peinture de la croix interne (Vert Fluo)
final centerPaint = Paint() final centerPaint = Paint()
..color = Colors.white.withValues(alpha: 0.8) ..color = const Color(0xFF00FF00)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 2; ..strokeWidth = 2.0;
const size = 10.0; // Cercle extérieur (Vert Fluo fin)
final circlePaint = Paint()
..color = const Color(0xFF00FF00).withValues(alpha: 0.9)
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
const circleRadius = 10.0; // Augmenté légèrement à 10 pour que la croix respire
final centerX = rect.center.dx; final centerX = rect.center.dx;
final centerY = rect.center.dy; final centerY = rect.center.dy;
// Ligne horizontale // 1. Dessiner le cercle de contour
canvas.drawCircle(rect.center, circleRadius, circlePaint);
// 2. Dessiner la croix UNIQUEMENT à l'intérieur du cercle
// Ligne horizontale (de gauche à droite, reste à l'intérieur)
canvas.drawLine( canvas.drawLine(
Offset(centerX - size, centerY), Offset(centerX - circleRadius, centerY),
Offset(centerX + size, centerY), Offset(centerX + circleRadius, centerY),
centerPaint, centerPaint,
); );
// Ligne verticale // Ligne verticale (de haut en bas, reste à l'intérieur)
canvas.drawLine( canvas.drawLine(
Offset(centerX, centerY - size), Offset(centerX, centerY - circleRadius),
Offset(centerX, centerY + size), Offset(centerX, centerY + circleRadius),
centerPaint, centerPaint,
); );
// Petit cercle central pour précision (optionnel, mais aide à viser)
canvas.drawCircle(
rect.center,
2,
Paint()..color = Colors.red.withValues(alpha: 0.6),
);
} }
@override @override
+210 -29
View File
@@ -19,6 +19,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon; late Weapon _weapon;
List<MaintenanceEntry> _maintenance = []; List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0; int _totalRounds = 0;
int _sessionCount = 0;
bool _isLoading = true; bool _isLoading = true;
@override @override
@@ -32,10 +33,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final history = await repository.getMaintenanceHistory(_weapon.id); final history = await repository.getMaintenanceHistory(_weapon.id);
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id); final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
if (mounted) { if (mounted) {
setState(() { setState(() {
_maintenance = history; _maintenance = history;
_totalRounds = rounds; _totalRounds = rounds;
_sessionCount = sessions;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -49,7 +52,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () => _showEditWeaponDialog(context), onPressed: _showEditWeaponDialog,
), ),
], ],
), ),
@@ -71,7 +74,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
), ),
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddMaintenanceDialog(context), onPressed: _showAddMaintenanceDialog,
label: const Text('Entretien'), label: const Text('Entretien'),
icon: const Icon(Icons.build), icon: const Icon(Icons.build),
), ),
@@ -93,7 +96,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
Expanded( Expanded(
child: _buildStatCard( child: _buildStatCard(
'Sessions', 'Sessions',
'N/A', _sessionCount.toString(),
Icons.history, Icons.history,
Colors.orange, Colors.orange,
), ),
@@ -149,7 +152,24 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
IconButton(
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
),
onPressed: _showEditAccessoriesDialog,
),
],
),
const Divider(), const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'), _buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'), _buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
@@ -203,7 +223,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
title: Text(entry.type.displayName), title: Text(entry.type.displayName),
subtitle: Text(entry.description), subtitle: Text(entry.description),
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)), trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
onLongPress: () => _confirmDeleteMaintenance(context, entry), onLongPress: () => _confirmDeleteMaintenance(entry),
), ),
); );
}, },
@@ -232,7 +252,36 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
} }
} }
void _showEditWeaponDialog(BuildContext context) async { // Recentre le champ qui prend le focus au milieu de la vue restante une fois
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
// (ex: Modérateur) se retrouve caché sous le clavier.
Widget _autoScrollOnFocus(Widget child) {
return Builder(
builder: (context) => Focus(
canRequestFocus: false,
skipTraversal: true,
onFocusChange: (hasFocus) {
if (!hasFocus) return;
// On attend que le clavier ait fini de redimensionner la vue.
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
});
},
child: child,
),
);
}
void _showEditWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController(text: _weapon.name); final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber); final caliberController = TextEditingController(text: _weapon.caliber);
final magCountController = TextEditingController(text: _weapon.magazineCount.toString()); final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
@@ -253,41 +302,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( _autoScrollOnFocus(TextField(
controller: nameController, controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'), decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
), )),
TextField( _autoScrollOnFocus(TextField(
controller: customNameController, controller: customNameController,
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'), decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
), )),
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
value: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
), ),
TextField( _autoScrollOnFocus(TextField(
controller: caliberController, controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'), decoration: const InputDecoration(labelText: 'Calibre'),
), )),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)), const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
TextField( _autoScrollOnFocus(TextField(
controller: opticController, controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'), decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
), )),
TextField( _autoScrollOnFocus(TextField(
controller: silencerController, controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'), decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
), )),
TextField( _autoScrollOnFocus(TextField(
controller: triggerController, controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'), decoration: const InputDecoration(labelText: 'Détente / Trigger'),
), )),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)), const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
Row( _autoScrollOnFocus(Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
@@ -305,12 +354,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
), ),
), ),
], ],
), )),
TextField( _autoScrollOnFocus(TextField(
controller: notesController, controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'), decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2, maxLines: 2,
), )),
], ],
), ),
), ),
@@ -336,17 +385,125 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
customName: customNameController.text.isEmpty ? null : customNameController.text, customName: customNameController.text.isEmpty ? null : customNameController.text,
); );
final repository = context.read<SessionRepository>();
await repository.updateWeapon(updatedWeapon); await repository.updateWeapon(updatedWeapon);
if (!mounted) return;
setState(() { setState(() {
_weapon = updatedWeapon; _weapon = updatedWeapon;
}); });
} }
} }
void _showAddMaintenanceDialog(BuildContext context) async { // Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
// pouvoir comparer les scores selon la configuration utilisée.
void _showEditAccessoriesDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier les accessoires'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
'pour pouvoir comparer les scores selon la configuration.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(
labelText: 'Surnom de la configuration',
hintText: 'ex: Echelon + Holosun',
),
)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
],
),
);
if (result != true || !mounted) return;
final newOptic = opticController.text.isEmpty ? null : opticController.text;
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
// Rien à faire si aucun accessoire n'a changé.
final unchanged = newOptic == _weapon.optic &&
newSilencer == _weapon.silencer &&
newTrigger == _weapon.trigger;
if (unchanged) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
);
return;
}
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
if (newCustomName == null) {
final accessories = [newOptic, newSilencer, newTrigger]
.where((a) => a != null && a.isNotEmpty)
.join(' / ');
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
}
final newWeapon = await repository.addWeapon(
name: _weapon.name,
type: _weapon.type,
caliber: _weapon.caliber,
magazineCount: _weapon.magazineCount,
magazineCapacity: _weapon.magazineCapacity,
notes: _weapon.notes,
optic: newOptic,
silencer: newSilencer,
trigger: newTrigger,
customName: newCustomName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
);
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
);
}
void _showAddMaintenanceDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final descController = TextEditingController(); final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning; MaintenanceType selectedType = MaintenanceType.cleaning;
DateTime selectedDate = DateTime.now();
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
@@ -357,7 +514,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
DropdownButtonFormField<MaintenanceType>( DropdownButtonFormField<MaintenanceType>(
value: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type d\'intervention'), decoration: const InputDecoration(labelText: 'Type d\'intervention'),
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
@@ -367,6 +524,27 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'), decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
maxLines: 2, maxLines: 2,
), ),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() => selectedDate = picked);
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date',
suffixIcon: Icon(Icons.calendar_today, size: 18),
),
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
),
),
], ],
), ),
actions: [ actions: [
@@ -378,18 +556,21 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
); );
if (result == true && descController.text.isNotEmpty) { if (result == true && descController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addMaintenanceEntry( await repository.addMaintenanceEntry(
weaponId: _weapon.id, weaponId: _weapon.id,
type: selectedType, type: selectedType,
description: descController.text, description: descController.text,
roundsSinceLast: _totalRounds, roundsSinceLast: _totalRounds,
date: selectedDate,
); );
if (!mounted) return;
_loadData(); _loadData();
} }
} }
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async { void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -406,8 +587,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
); );
if (confirmed == true) { if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteMaintenanceEntry(entry.id); await repository.deleteMaintenanceEntry(entry.id);
if (!mounted) return;
_loadData(); _loadData();
} }
} }
+113 -27
View File
@@ -7,7 +7,12 @@ import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart'; import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget { class WeaponListScreen extends StatefulWidget {
const WeaponListScreen({super.key}); /// Incrémenté par la navigation à chaque ouverture de l'onglet Armurerie
/// (l'écran est gardé vivant par l'IndexedStack et ne se rafraîchit pas
/// seul : sans ça, un import de sauvegarde resterait invisible).
final int refreshTick;
const WeaponListScreen({super.key, this.refreshTick = 0});
@override @override
State<WeaponListScreen> createState() => _WeaponListScreenState(); State<WeaponListScreen> createState() => _WeaponListScreenState();
@@ -23,6 +28,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
_loadWeapons(); _loadWeapons();
} }
@override
void didUpdateWidget(WeaponListScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshTick != widget.refreshTick) {
_loadWeapons();
}
}
Future<void> _loadWeapons() async { Future<void> _loadWeapons() async {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons(); final weapons = await repository.getWeapons();
@@ -46,7 +59,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
? _buildEmptyState() ? _buildEmptyState()
: _buildWeaponList(), : _buildWeaponList(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _showAddWeaponDialog(context), onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
); );
@@ -62,7 +75,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
const Text('Aucune arme enregistrée'), const Text('Aucune arme enregistrée'),
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( ElevatedButton(
onPressed: () => _showAddWeaponDialog(context), onPressed: _showAddWeaponDialog,
child: const Text('Ajouter ma première arme'), child: const Text('Ajouter ma première arme'),
), ),
], ],
@@ -76,26 +89,11 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
itemCount: _weapons.length, itemCount: _weapons.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final weapon = _weapons[index]; final weapon = _weapons[index];
final accessories = _accessoryChips(weapon);
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
child: ListTile( child: InkWell(
leading: CircleAvatar( borderRadius: BorderRadius.circular(12),
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${weapon.type.displayName}${weapon.caliber}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
onTap: () async { onTap: () async {
await Navigator.push( await Navigator.push(
context, context,
@@ -103,14 +101,100 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
); );
_loadWeapons(); // Reload in case it was edited or maintenance was added _loadWeapons(); // Reload in case it was edited or maintenance was added
}, },
onLongPress: () => _confirmDelete(context, weapon), onLongPress: () => _confirmDelete(weapon),
child: Padding(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text(
'${weapon.type.displayName}${weapon.caliber}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
],
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
), ),
); );
}, },
); );
} }
void _showAddWeaponDialog(BuildContext context) async { // Construit la liste des "puces" d'accessoires renseignés pour une arme.
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!));
}
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
items.add((Icons.volume_off, weapon.silencer!));
}
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
items.add((Icons.touch_app, weapon.trigger!));
}
return items.map((item) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
const SizedBox(width: 4),
Text(item.$2, style: const TextStyle(fontSize: 12)),
],
),
);
}).toList();
}
void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController(); final nameController = TextEditingController();
final caliberController = TextEditingController(); final caliberController = TextEditingController();
final magCountController = TextEditingController(text: '2'); final magCountController = TextEditingController(text: '2');
@@ -131,7 +215,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'), decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
), ),
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
value: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
@@ -171,7 +255,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
); );
if (result == true && nameController.text.isNotEmpty) { if (result == true && nameController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addWeapon( await repository.addWeapon(
name: nameController.text, name: nameController.text,
type: selectedType, type: selectedType,
@@ -179,11 +262,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
magazineCount: int.tryParse(magCountController.text) ?? 1, magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10, magazineCapacity: int.tryParse(magCapController.text) ?? 10,
); );
if (!mounted) return;
_loadWeapons(); _loadWeapons();
} }
} }
void _confirmDelete(BuildContext context, Weapon weapon) async { void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -200,8 +286,8 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
); );
if (confirmed == true) { if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteWeapon(weapon.id); await repository.deleteWeapon(weapon.id);
if (!mounted) return;
_loadWeapons(); _loadWeapons();
} }
} }
+45 -9
View File
@@ -11,7 +11,13 @@ import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart'; import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key}); /// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
/// tant qu'on ne tire pas manuellement pour rafraîchir).
final int refreshTick;
const HistoryScreen({super.key, this.refreshTick = 0});
@override @override
State<HistoryScreen> createState() => _HistoryScreenState(); State<HistoryScreen> createState() => _HistoryScreenState();
@@ -31,6 +37,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
_loadSessions(); _loadSessions();
} }
@override
void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
if (oldWidget.refreshTick != widget.refreshTick) {
_loadSessions();
}
}
Future<void> _loadSessions() async { Future<void> _loadSessions() async {
if (!mounted) return; if (!mounted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
@@ -43,6 +59,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() { setState(() {
_sessions = sessions; _sessions = sessions;
// --- FILTRAGE PAR TYPE DE CIBLE ---
// Une session est retenue si au moins une de ses cibles est du type choisi.
if (_filterType != null) {
_sessions = _sessions
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
.toList();
}
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE --- // --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) { if (_selectedDateRange != null) {
_sessions = _sessions.where((s) { _sessions = _sessions.where((s) {
@@ -103,7 +127,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc) onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor, secondary: AppTheme.primaryColor,
), ),
dialogBackgroundColor: Colors.white, dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor), style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
), ),
@@ -125,17 +149,29 @@ class _HistoryScreenState extends State<HistoryScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Historique'), title: const Text('Historique'),
actions: [ actions: [
PopupMenuButton<TargetType?>( // NOTE : on passe par un String sentinelle ('all') car un
icon: const Icon(Icons.filter_list), // PopupMenuItem avec value null ne déclenche jamais onSelected
onSelected: (type) { // (Flutter l'interprète comme une annulation du menu).
setState(() => _filterType = type); PopupMenuButton<String>(
icon: Icon(
Icons.filter_list,
// Icône colorée quand un filtre est actif, pour le rendre visible.
color: _filterType != null ? AppTheme.primaryColor : null,
),
onSelected: (value) {
setState(() {
_filterType =
value == 'all' ? null : TargetType.fromString(value);
});
_loadSessions(); _loadSessions();
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem(value: null, child: Text('Tous')), const PopupMenuItem(value: 'all', child: Text('Tous')),
...TargetType.values.map( ...TargetType.values.map(
(type) => (type) => PopupMenuItem(
PopupMenuItem(value: type, child: Text(type.displayName)), value: type.name,
child: Text(type.displayName),
),
), ),
], ],
), ),
+133 -7
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../main_navigation_holder.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
@@ -14,7 +15,12 @@ import '../session/session_provider.dart';
import 'widgets/stats_card.dart'; import 'widgets/stats_card.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); /// Incrémenté par la navigation à chaque ouverture de l'onglet Accueil
/// (l'écran est gardé vivant par l'IndexedStack : sans ça, un import de
/// sauvegarde resterait invisible sur le dashboard).
final int refreshTick;
const HomeScreen({super.key, this.refreshTick = 0});
@override @override
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
@@ -25,12 +31,51 @@ class _HomeScreenState extends State<HomeScreen> {
List<Session> _recentSessions = []; List<Session> _recentSessions = [];
bool _isLoading = true; bool _isLoading = true;
SessionProvider? _sessionProvider;
bool _wasSessionActive = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadStats(); _loadStats();
} }
@override
void didUpdateWidget(HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshTick != widget.refreshTick) {
_loadStats();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final provider = context.read<SessionProvider>();
if (provider != _sessionProvider) {
_sessionProvider?.removeListener(_onSessionChanged);
_sessionProvider = provider;
_sessionProvider!.addListener(_onSessionChanged);
_wasSessionActive = provider.isSessionActive;
}
}
void _onSessionChanged() {
final isActive = _sessionProvider?.isSessionActive ?? false;
// Quand une session vient de se terminer, on rafraîchit les stats
// automatiquement pour prendre en compte la dernière session.
if (_wasSessionActive && !isActive) {
_loadStats();
}
_wasSessionActive = isActive;
}
@override
void dispose() {
_sessionProvider?.removeListener(_onSessionChanged);
super.dispose();
}
Future<void> _loadStats() async { Future<void> _loadStats() async {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final stats = await repository.getStatistics(); final stats = await repository.getStatistics();
@@ -125,24 +170,105 @@ class _HomeScreenState extends State<HomeScreen> {
final sessionProvider = context.watch<SessionProvider>(); final sessionProvider = context.watch<SessionProvider>();
final isSessionActive = sessionProvider.isSessionActive; final isSessionActive = sessionProvider.isSessionActive;
// Si aucune session n'est active, on affiche juste le bouton de départ habituel
if (!isSessionActive) {
return ElevatedButton.icon( return ElevatedButton.icon(
onPressed: () => _navigateToSessionSetup(context), onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: isSessionActive ? AppTheme.secondaryColor : AppTheme.primaryColor, backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius), borderRadius: BorderRadius.circular(AppConstants.borderRadius),
), ),
), ),
icon: Icon(isSessionActive ? Icons.play_circle_outline : Icons.add_circle_outline, size: 28), icon: const Icon(Icons.add_circle_outline, size: 28),
label: Text( label: const Text(
isSessionActive ? 'Continuer la session' : 'Démarrer la session', 'Nouvelle session',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
); );
} }
// MODIFICATION : Alignement sur une seule ligne (80% / 20%) avec une croix pour l'annulation
return Row(
children: [
// 80% de largeur pour continuer la session
Expanded(
flex: 8,
child: ElevatedButton.icon(
onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.play_circle_outline, size: 28),
label: const Text(
'Continuer la session',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 12),
// 20% de largeur pour terminer/annuler la session (Bouton Croix)
Expanded(
flex: 2,
child: OutlinedButton(
onPressed: () {
// Boite de dialogue de confirmation pour éviter les erreurs
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Terminer la session ?'),
content: const Text('Êtes-vous sûr de vouloir clôturer la session en cours ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('ANNULER'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
// Clôture propre de la session dans le State global
context.read<SessionProvider>().endSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Session terminée avec succès !'),
backgroundColor: AppTheme.successColor,
),
);
// Fin de session : on bascule sur les statistiques.
openMainTab(mainTabStats);
},
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
),
],
);
},
);
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 20), // Même hauteur que le bouton continuer
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
child: const Icon(Icons.close, size: 28),
),
),
],
);
}
Widget _buildStatsSection() { Widget _buildStatsSection() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -229,7 +355,7 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded( Expanded(
child: StatsCard( child: StatsCard(
icon: Icons.emoji_events, icon: Icons.emoji_events,
title: 'Meilleur', title: 'Meilleur score',
value: '${_stats!['bestScore']}', value: '${_stats!['bestScore']}',
color: AppTheme.successColor, color: AppTheme.successColor,
), ),
+4 -3
View File
@@ -1,9 +1,9 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart'; import '../../data/models/target_analysis.dart';
import '../../data/models/weapon.dart';
class SessionProvider extends ChangeNotifier { class SessionProvider extends ChangeNotifier {
DateTime? _sessionDate;
DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName; String? _currentWeaponName;
String? _currentWeaponId; String? _currentWeaponId;
int _shotsPerTarget = 5; int _shotsPerTarget = 5;
@@ -23,11 +23,12 @@ class SessionProvider extends ChangeNotifier {
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore); int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length; int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25}) { void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) {
_currentWeaponName = weaponName; _currentWeaponName = weaponName;
_currentWeaponId = weaponId; _currentWeaponId = weaponId;
_shotsPerTarget = shots; _shotsPerTarget = shots;
_distance = distance; _distance = distance;
_sessionDate = date ?? DateTime.now();
_activeSessionId = sessionId; _activeSessionId = sessionId;
_currentAnalyses.clear(); _currentAnalyses.clear();
_isSessionActive = true; _isSessionActive = true;
+119 -52
View File
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import 'session_provider.dart'; import 'session_provider.dart';
import '../capture/capture_screen.dart'; import '../capture/capture_screen.dart';
import '../garage/weapon_list_screen.dart';
class SessionSetupScreen extends StatefulWidget { class SessionSetupScreen extends StatefulWidget {
const SessionSetupScreen({super.key}); const SessionSetupScreen({super.key});
@@ -16,13 +18,16 @@ class SessionSetupScreen extends StatefulWidget {
class _SessionSetupScreenState extends State<SessionSetupScreen> { class _SessionSetupScreenState extends State<SessionSetupScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _weaponController = TextEditingController();
int _shotsPerTarget = 5; int _shotsPerTarget = 5;
int _distance = 25; int _distance = 25;
List<Weapon> _availableWeapons = []; List<Weapon> _availableWeapons = [];
Weapon? _selectedWeapon; Weapon? _selectedWeapon;
bool _isLoadingWeapons = true; bool _isLoadingWeapons = true;
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
DateTime _selectedDate = DateTime.now();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -30,34 +35,63 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
} }
Future<void> _loadWeapons() async { Future<void> _loadWeapons() async {
setState(() => _isLoadingWeapons = true);
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons(); final weapons = await repository.getWeapons();
if (mounted) { if (mounted) {
setState(() { setState(() {
_availableWeapons = weapons; _availableWeapons = weapons;
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
_selectedWeapon = _availableWeapons.first;
_updateSettingsForWeapon(_selectedWeapon!);
}
_isLoadingWeapons = false; _isLoadingWeapons = false;
}); });
} }
} }
@override void _updateSettingsForWeapon(Weapon weapon) {
void dispose() { _shotsPerTarget = weapon.magazineCapacity;
_weaponController.dispose(); _distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
super.dispose(); }
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'), // Force le calendrier en français
);
if (picked != null && picked != _selectedDate) {
setState(() {
// On garde aussi l'heure actuelle lors du changement de date
final now = DateTime.now();
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
});
}
} }
void _startSession() { void _startSession() {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate() && _selectedWeapon != null) {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final sessionProvider = context.read<SessionProvider>(); final sessionProvider = context.read<SessionProvider>();
final sessionId = repository.generateId(); final sessionId = repository.generateId();
// PASSE-PARTOUT : On envoie la session au provider.
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
sessionProvider.startSession( sessionProvider.startSession(
_selectedWeapon != null ? _selectedWeapon!.displayName : _weaponController.text, _selectedWeapon!.displayName,
_shotsPerTarget, _shotsPerTarget,
sessionId, sessionId,
weaponId: _selectedWeapon?.id, weaponId: _selectedWeapon!.id,
distance: _distance, distance: _distance,
date: _selectedDate,
); );
Navigator.pushReplacement( Navigator.pushReplacement(
@@ -69,6 +103,9 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Petit formatage sympa en français (ex: "27 mai 2026")
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Configuration de la session'), title: const Text('Configuration de la session'),
@@ -83,69 +120,97 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( const Text(
'Choix de l\'arme', 'Informations générales',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_availableWeapons.isNotEmpty) ...[ // AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
DropdownButtonFormField<Weapon?>( if (_availableWeapons.isEmpty)
value: _selectedWeapon, Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
),
child: Column(
children: [
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
const SizedBox(height: 8),
const Text(
'Ton armurerie est vide.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
const Text(
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
).then((_) {
_loadWeapons();
});
},
icon: const Icon(Icons.add),
label: const Text('ALLER À MON ARMURERIE'),
),
],
),
)
else
Column(
children: [
DropdownButtonFormField<Weapon>(
initialValue: _selectedWeapon,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Sélectionner une arme de l\'armurerie', labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield), prefixIcon: const Icon(Icons.shield),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius), borderRadius: BorderRadius.circular(AppConstants.borderRadius),
), ),
), ),
items: [ items: _availableWeapons.map((w) => DropdownMenuItem(
const DropdownMenuItem<Weapon?>(
value: null,
child: Text('Nouvelle arme / Autre'),
),
..._availableWeapons.map((w) => DropdownMenuItem(
value: w, value: w,
child: Text(w.displayName), child: Text(w.displayName),
)), )).toList(),
],
onChanged: (value) { onChanged: (value) {
if (value != null) {
setState(() { setState(() {
_selectedWeapon = value; _selectedWeapon = value;
if (value != null) { _updateSettingsForWeapon(value);
_weaponController.text = value.displayName;
_shotsPerTarget = value.magazineCapacity;
// Auto-set distance based on weapon type if needed
_distance = (value.type == WeaponType.handgun) ? 25 : 50;
} else {
_weaponController.clear();
}
}); });
}
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Center(child: Text('OU', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold))),
const SizedBox(height: 16),
],
// Weapon field (manual entry if no weapon selected) // NOUVEAU CHAMP : Sélecteur de date interactif
TextFormField( InkWell(
controller: _weaponController, onTap: _availableWeapons.isEmpty ? null : _pickDate,
enabled: _selectedWeapon == null, borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: IgnorePointer(
child: TextFormField(
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Nom de l\'arme', labelText: 'Date de la session',
hintText: 'ex: Glock 17, CZ Shadow 2...', prefixIcon: const Icon(Icons.calendar_today),
prefixIcon: const Icon(Icons.edit),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius), borderRadius: BorderRadius.circular(AppConstants.borderRadius),
), ),
), ),
validator: (value) { controller: TextEditingController(text: formattedDate),
if (value == null || value.isEmpty) {
return 'Veuillez renseigner votre arme';
}
return null;
},
), ),
),
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
// Distance selector // Distance selector
@@ -161,9 +226,9 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
value: _distance.toDouble(), value: _distance.toDouble(),
min: 5, min: 5,
max: 300, max: 300,
divisions: 59, // (300-5)/5 = 59 divisions for 5m steps divisions: 59,
label: '${_distance}m', label: '${_distance}m',
onChanged: (value) { onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() { setState(() {
_distance = value.round(); _distance = value.round();
}); });
@@ -193,7 +258,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
// Shots per target // Shots per target
const Text( const Text(
'Nombre de balles par cible', 'Nombre de balles pour la cible actuelle',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -203,10 +268,10 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
child: Slider( child: Slider(
value: _shotsPerTarget.toDouble(), value: _shotsPerTarget.toDouble(),
min: 1, min: 1,
max: 30, max: 50,
divisions: 29, divisions: 49,
label: '$_shotsPerTarget', label: '$_shotsPerTarget',
onChanged: (value) { onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() { setState(() {
_shotsPerTarget = value.round(); _shotsPerTarget = value.round();
}); });
@@ -235,7 +300,9 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
const SizedBox(height: 48), const SizedBox(height: 48),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _startSession, onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor, backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
+82 -4
View File
@@ -67,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) setState(() => _isLoadingStats = false); if (mounted) setState(() => _isLoadingStats = false);
} }
} catch (e) { } catch (e) {
print('Erreur lors du chargement des statistiques: $e'); debugPrint('Erreur lors du chargement des statistiques: $e');
if (mounted) setState(() => _isLoadingStats = false); if (mounted) setState(() => _isLoadingStats = false);
} }
} }
@@ -85,8 +85,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _showThemeDialog() { void _showThemeDialog() {
final themeProvider = context.read<ThemeProvider>();
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -247,6 +245,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
// Rapport de bug autonome : on collecte une description + les infos
// techniques, et on copie un rapport prêt à coller dans un email de support.
void _showReportBugDialog() {
final descController = TextEditingController();
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
const appVersion = '1.0.0';
final platform = Theme.of(context).platform.name;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler un bug'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Décrivez le problème : ce que vous faisiez, ce qui était attendu et ce qui s\'est passé.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
TextField(
controller: descController,
maxLines: 5,
decoration: const InputDecoration(
hintText: 'Ex: l\'application se ferme quand j\'ouvre une session...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
'Infos techniques jointes : version $appVersion$platform',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier le rapport'),
onPressed: () {
final desc = descController.text.trim();
final report = StringBuffer()
..writeln('--- Rapport de bug ---')
..writeln('Version : $appVersion')
..writeln('Plateforme : $platform')
..writeln('Date : ${DateTime.now().toIso8601String()}')
..writeln('')
..writeln('Description :')
..writeln(desc.isEmpty ? '(non renseignée)' : desc);
Clipboard.setData(ClipboardData(text: report.toString()));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Rapport copié — collez-le dans un email à $supportEmail'),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 5),
),
);
},
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -303,7 +374,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)), subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary), secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled, value: _isUploadEnabled,
activeColor: AppTheme.primaryColor, activeThumbColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1), side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
@@ -347,6 +418,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: '1.0.0', subtitle: '1.0.0',
onTap: () {}, onTap: () {},
), ),
_buildSettingsTile(
context: context,
icon: Icons.bug_report_outlined,
title: 'Signaler un bug',
subtitle: 'Aidez-nous à corriger les problèmes',
onTap: _showReportBugDialog,
),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.privacy_tip_outlined, icon: Icons.privacy_tip_outlined,
+537 -12
View File
@@ -1,16 +1,27 @@
import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
import 'package:path/path.dart' as p;
import 'package:share_plus/share_plus.dart';
import '../../core/widgets/metric_info_button.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import '../../services/backup_service.dart';
import '../../services/statistics_service.dart'; import '../../services/statistics_service.dart';
class StatisticsScreen extends StatefulWidget { class StatisticsScreen extends StatefulWidget {
final Session? singleSession; final Session? singleSession;
const StatisticsScreen({super.key, this.singleSession}); /// Incrémenté par la navigation à chaque fois que l'onglet Stats est ouvert,
/// pour forcer un rechargement des données (l'écran est gardé vivant par un
/// IndexedStack, sinon les filtres arme/distance resteraient périmés).
final int refreshTick;
const StatisticsScreen({super.key, this.singleSession, this.refreshTick = 0});
@override @override
State<StatisticsScreen> createState() => _StatisticsScreenState(); State<StatisticsScreen> createState() => _StatisticsScreenState();
@@ -18,7 +29,7 @@ class StatisticsScreen extends StatefulWidget {
class _StatisticsScreenState extends State<StatisticsScreen> { class _StatisticsScreenState extends State<StatisticsScreen> {
final StatisticsService _statisticsService = StatisticsService(); final StatisticsService _statisticsService = StatisticsService();
StatsPeriod _selectedPeriod = StatsPeriod.all; final StatsPeriod _selectedPeriod = StatsPeriod.all;
SessionStatistics? _statistics; SessionStatistics? _statistics;
bool _isLoading = true; bool _isLoading = true;
List<Session> _allSessions = []; List<Session> _allSessions = [];
@@ -29,12 +40,34 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> _availableWeapons = ['Toutes']; List<String> _availableWeapons = ['Toutes'];
List<String> _availableDistances = ['Toutes']; List<String> _availableDistances = ['Toutes'];
// --- Comparateur de sessions ---
// Quand 2 sessions sont sélectionnées, l'écran passe en mode comparaison :
// un switch permet d'alterner l'affichage des stats entre la session A et B.
Session? _compareA;
Session? _compareB;
bool _showingB = false; // false = on affiche A, true = on affiche B
bool get _compareMode => _compareA != null && _compareB != null;
// --- Sauvegarde (export/import JSON) ---
bool _isBackupBusy = false;
final GlobalKey _exportButtonKey = GlobalKey();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics()); WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
} }
@override
void didUpdateWidget(StatisticsScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour rafraîchir les listes
// de filtres (armes/distances) et les stats avec les nouvelles sessions.
if (oldWidget.refreshTick != widget.refreshTick) {
_loadStatistics();
}
}
Future<void> _loadStatistics() async { Future<void> _loadStatistics() async {
if (!mounted) return; if (!mounted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
@@ -79,6 +112,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
} }
void _calculateStats() { void _calculateStats() {
// Mode comparaison : on calcule les stats sur la seule session active
// (A ou B selon le switch), sans filtre de période.
if (_compareMode) {
final active = _showingB ? _compareB! : _compareA!;
_statistics = _statisticsService.calculateStatistics(
[active],
period: StatsPeriod.all,
);
return;
}
// Filtrer les sessions avant calcul // Filtrer les sessions avant calcul
final filteredSessions = _allSessions.where((s) { final filteredSessions = _allSessions.where((s) {
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon; final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
@@ -92,6 +136,80 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
String _sessionLabel(Session s) {
final d = s.createdAt;
final date = '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
return '${s.weapon}$date${s.totalScore} pts';
}
// Sélection des 2 sessions à comparer via un dialog à deux listes déroulantes.
Future<void> _openCompareDialog() async {
if (_allSessions.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Il faut au moins 2 sessions enregistrées pour comparer.')),
);
return;
}
Session? a = _compareA ?? _allSessions[0];
Session? b = _compareB ?? _allSessions[1];
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Comparer 2 sessions'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<Session>(
initialValue: a,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Session A'),
items: _allSessions
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() => a = v),
),
const SizedBox(height: 12),
DropdownButtonFormField<Session>(
initialValue: b,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Session B'),
items: _allSessions
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() => b = v),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Comparer')),
],
),
),
);
if (confirmed == true && a != null && b != null) {
setState(() {
_compareA = a;
_compareB = b;
_showingB = false;
_calculateStats();
});
}
}
void _exitCompareMode() {
setState(() {
_compareA = null;
_compareB = null;
_showingB = false;
_calculateStats();
});
}
List<double> _getScoreHistory() { List<double> _getScoreHistory() {
if (_statistics == null || _statistics!.sessions.isEmpty) return [0]; if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
// Sort sessions by date and take last 10 // Sort sessions by date and take last 10
@@ -123,11 +241,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: RefreshIndicator( : Column(
onRefresh: _loadStatistics, children: [
child: SingleChildScrollView( // HEADER COLLANT : filtres + comparateur toujours visibles
physics: const AlwaysScrollableScrollPhysics(), Material(
padding: const EdgeInsets.all(16), elevation: 2,
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column( child: Column(
children: [ children: [
// 1. FILTRES (Arme et Distance) // 1. FILTRES (Arme et Distance)
@@ -162,8 +283,22 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
], ],
), ),
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) // 2. DONNÉES RAPIDES (Tirs et Sessions)
Row( Row(
children: [ children: [
@@ -191,18 +326,46 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
'Score', 'Score',
'${_statistics?.totalScore ?? 0}', '${_statistics?.totalScore ?? 0}',
_getScoreHistory(), _getScoreHistory(),
explanations: const [
MetricExplanation(
'Score',
'Total des points marqués sur la période sélectionnée.',
),
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildChartSection( _buildChartSection(
'Précision', 'Précision',
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%', '${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
_getPrecisionHistory(), _getPrecisionHistory(),
explanations: const [
MetricExplanation(
'Précision',
'Proximité moyenne de vos impacts par rapport au centre '
'de la cible (calculée sur les distances).',
),
MetricExplanation(
'À ne pas confondre',
'C\'est différent de la « Réussite » affichée dans une '
'session, qui se base sur les points marqués. Les deux '
'mesurent des choses distinctes, leurs pourcentages '
'ne sont donc pas identiques.',
),
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildChartSection( _buildChartSection(
'Groupement moyen', 'Étalement moyen',
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%', '${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history _getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
explanations: const [
MetricExplanation(
'Étalement moyen',
'Étalement moyen de vos groupements : distance entre les '
'impacts les plus éloignés, en % de la largeur de '
'l\'image. Plus c\'est bas, plus vos tirs sont serrés.',
),
],
), ),
const SizedBox(height: 25), const SizedBox(height: 25),
@@ -220,11 +383,107 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
_buildBiasWarning(), _buildBiasWarning(),
], ],
const SizedBox(height: 25),
// 5. SAUVEGARDE : export/import de toutes les données
_buildBackupSection(),
const SizedBox(height: 30), const SizedBox(height: 30),
], ],
), ),
), ),
), ),
),
],
),
);
}
// Comparateur : bouton pour choisir 2 sessions, puis switch pour alterner.
Widget _buildComparator() {
final theme = Theme.of(context);
if (!_compareMode) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openCompareDialog,
icon: const Icon(Icons.compare_arrows),
label: const Text('Comparer 2 sessions'),
),
);
}
final activeColor = const Color(0xFF1A73E8);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: activeColor.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.compare_arrows, color: activeColor, size: 18),
const SizedBox(width: 6),
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)),
const Spacer(),
IconButton(
tooltip: 'Quitter la comparaison',
icon: const Icon(Icons.close, size: 18),
onPressed: _exitCompareMode,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
// Switch pour alterner entre les 2 sessions.
Row(
children: [
Expanded(
child: Text(
'A · ${_sessionLabel(_compareA!)}',
style: TextStyle(
fontSize: 12,
color: _showingB ? theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5) : null,
fontWeight: _showingB ? FontWeight.normal : FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: _showingB,
activeThumbColor: activeColor,
onChanged: (v) => setState(() {
_showingB = v;
_calculateStats();
}),
),
Expanded(
child: Text(
'B · ${_sessionLabel(_compareB!)}',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: _showingB ? null : theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5),
fontWeight: _showingB ? FontWeight.bold : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
'Affichage : session ${_showingB ? 'B' : 'A'}',
style: TextStyle(fontSize: 11, color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6)),
),
],
),
); );
} }
@@ -302,8 +561,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildChartSection( Widget _buildChartSection(
String title, String title,
String value, String value,
List<double> dataPoints, List<double> dataPoints, {
) { List<MetricExplanation>? explanations,
}) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -313,11 +573,19 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Text( Text(
title, title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14), style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
), ),
if (explanations != null) ...[
const Spacer(),
MetricInfoButton(title: title, explanations: explanations),
],
],
),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
children: [ children: [
@@ -628,6 +896,263 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
); );
} }
// --- SAUVEGARDE : EXPORT / IMPORT JSON ---
Widget _buildBackupSection() {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.save_alt, color: theme.textTheme.titleMedium?.color, size: 20),
const SizedBox(width: 8),
Text(
'Sauvegarde',
style: TextStyle(
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 4),
Text(
'Exporte toutes tes sessions, tes stats et ton armurerie dans un '
'fichier JSON, à envoyer où tu veux. L\'import fusionne le fichier '
'avec tes données actuelles (rien n\'est effacé).',
style: TextStyle(
color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6),
fontSize: 12,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
key: _exportButtonKey,
onPressed: _isBackupBusy ? null : _exportBackup,
icon: const Icon(Icons.ios_share, size: 18),
label: const Text('Exporter'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isBackupBusy ? null : _importBackup,
icon: const Icon(Icons.file_download_outlined, size: 18),
label: const Text('Importer'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
if (_isBackupBusy) ...[
const SizedBox(height: 12),
const LinearProgressIndicator(minHeight: 2),
],
],
),
);
}
BackupService _backupService() =>
BackupService(repository: context.read<SessionRepository>());
Future<void> _exportBackup() async {
final includeImages = await _askIncludeImages();
if (includeImages == null || !mounted) return;
setState(() => _isBackupBusy = true);
try {
final file = await _backupService().exportToFile(
includeImages: includeImages,
);
if (!mounted) return;
// sharePositionOrigin : obligatoire pour l'iPad, ignoré ailleurs.
final box = _exportButtonKey.currentContext?.findRenderObject() as RenderBox?;
final origin = box != null && box.hasSize
? box.localToGlobal(Offset.zero) & box.size
: null;
await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path, mimeType: 'application/json')],
fileNameOverrides: [p.basename(file.path)],
subject: 'Sauvegarde IMPACT',
text: 'Sauvegarde de mes sessions de tir (${p.basename(file.path)})',
sharePositionOrigin: origin,
),
);
} catch (e) {
_showMessage('Export impossible : $e', isError: true);
} finally {
if (mounted) setState(() => _isBackupBusy = false);
}
}
/// Les photos de cibles alourdissent énormément le fichier : on laisse le
/// choix entre une sauvegarde légère (données seules) et une sauvegarde
/// complète (photos encodées dans le JSON).
Future<bool?> _askIncludeImages() {
var includeImages = false;
return showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Exporter mes données'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Le fichier contiendra toutes tes sessions (cibles, impacts, '
'scores), tes statistiques et ton armurerie.',
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: includeImages,
onChanged: (v) => setDialogState(() => includeImages = v),
title: const Text('Inclure les photos des cibles'),
subtitle: const Text(
'Sauvegarde complète, mais fichier beaucoup plus lourd.',
style: TextStyle(fontSize: 12),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, includeImages),
child: const Text('Exporter'),
),
],
),
),
);
}
Future<void> _importBackup() async {
const jsonGroup = XTypeGroup(
label: 'Sauvegarde IMPACT (.json)',
extensions: ['json'],
// Android renvoie parfois un type générique pour un .json : on accepte
// large, le contenu est validé à la lecture de toute façon.
mimeTypes: ['application/json', 'text/plain', 'application/octet-stream'],
uniformTypeIdentifiers: ['public.json', 'public.text'],
);
final picked = await openFile(acceptedTypeGroups: const [jsonGroup]);
if (picked == null || !mounted) return;
setState(() => _isBackupBusy = true);
try {
final service = _backupService();
final preview = await service.readBackup(File(picked.path));
if (!mounted) return;
final confirmed = await _confirmImport(preview);
if (confirmed != true || !mounted) return;
final result = await service.applyBackup(preview);
await _loadStatistics();
if (!mounted) return;
final details = [
'${result.sessions} session(s)',
'${result.weapons} arme(s)',
if (result.maintenance > 0) '${result.maintenance} entretien(s)',
if (result.images > 0) '${result.images} photo(s)',
].join(' · ');
_showMessage(
result.errors.isEmpty
? 'Import terminé : $details'
: 'Import terminé : $details${result.errors.length} entrée(s) ignorée(s)',
);
} on BackupFormatException catch (e) {
_showMessage(e.message, isError: true);
} catch (e) {
_showMessage('Import impossible : $e', isError: true);
} finally {
if (mounted) setState(() => _isBackupBusy = false);
}
}
Future<bool?> _confirmImport(BackupPreview preview) {
final date = preview.exportedAt;
final dateLabel = date == null
? null
: '${date.day.toString().padLeft(2, '0')}/'
'${date.month.toString().padLeft(2, '0')}/${date.year}';
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Importer cette sauvegarde ?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (dateLabel != null) Text('Exportée le $dateLabel'),
if (dateLabel != null) const SizedBox(height: 8),
Text('${preview.sessionCount} session(s)'),
Text('${preview.targetCount} cible(s), ${preview.shotCount} impact(s)'),
Text('${preview.weaponCount} arme(s), ${preview.maintenanceCount} entretien(s)'),
Text(preview.hasImages
? '• Photos des cibles incluses'
: '• Sans photos de cibles'),
const SizedBox(height: 12),
const Text(
'Les données actuelles sont conservées. Une session déjà '
'présente est simplement mise à jour.',
style: TextStyle(fontSize: 12),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Importer'),
),
],
),
);
}
void _showMessage(String message, {bool isError = false}) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red.shade700 : null,
duration: const Duration(seconds: 4),
),
);
}
} }
class _HeatMapPainter extends CustomPainter { class _HeatMapPainter extends CustomPainter {
-10
View File
@@ -7,10 +7,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'app.dart'; import 'app.dart';
import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart';
import 'data/repositories/session_repository.dart'; import 'data/repositories/session_repository.dart';
import 'services/target_detection_service.dart';
import 'services/score_calculator_service.dart'; import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart'; import 'services/grouping_analyzer_service.dart';
import 'services/image_processing_service.dart';
import 'features/session/session_provider.dart'; import 'features/session/session_provider.dart';
void main() async { void main() async {
@@ -32,14 +30,6 @@ void main() async {
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
Provider<ImageProcessingService>(
create: (_) => ImageProcessingService(),
),
Provider<TargetDetectionService>(
create: (context) => TargetDetectionService(
imageProcessingService: context.read<ImageProcessingService>(),
),
),
Provider<ScoreCalculatorService>( Provider<ScoreCalculatorService>(
create: (_) => ScoreCalculatorService(), create: (_) => ScoreCalculatorService(),
), ),
+38 -9
View File
@@ -5,6 +5,23 @@ import 'features/statistics/statistics_screen.dart';
import 'features/garage/weapon_list_screen.dart'; import 'features/garage/weapon_list_screen.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
/// Index des onglets de la barre de navigation principale.
const int mainTabHome = 0;
const int mainTabHistory = 1;
const int mainTabStats = 2;
const int mainTabGarage = 3;
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel
/// écran (ex. fin de session -> onglet Stats).
final GlobalKey<State<MainNavigationHolder>> mainNavKey =
GlobalKey<State<MainNavigationHolder>>();
/// Ouvre l'onglet [index] de la navigation principale.
void openMainTab(int index) {
final state = mainNavKey.currentState;
if (state is _MainNavigationHolderState) state.selectTab(index);
}
class MainNavigationHolder extends StatefulWidget { class MainNavigationHolder extends StatefulWidget {
const MainNavigationHolder({super.key}); const MainNavigationHolder({super.key});
@@ -15,25 +32,37 @@ class MainNavigationHolder extends StatefulWidget {
class _MainNavigationHolderState extends State<MainNavigationHolder> { class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0; int _selectedIndex = 0;
final List<Widget> _screens = [ // Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
const HomeScreen(), // rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
const HistoryScreen(), // rafraîchissent pas seuls).
const StatisticsScreen(), int _statsTick = 0;
const WeaponListScreen(), int _historyTick = 0;
]; int _homeTick = 0;
int _garageTick = 0;
void _onItemTapped(int index) { void selectTab(int index) {
if (!mounted) return;
setState(() { setState(() {
_selectedIndex = index; _selectedIndex = index;
if (index == mainTabHome) _homeTick++;
if (index == mainTabHistory) _historyTick++;
if (index == mainTabStats) _statsTick++;
if (index == mainTabGarage) _garageTick++;
}); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screens = [
HomeScreen(refreshTick: _homeTick),
HistoryScreen(refreshTick: _historyTick),
StatisticsScreen(refreshTick: _statsTick),
WeaponListScreen(refreshTick: _garageTick),
];
return Scaffold( return Scaffold(
body: IndexedStack( body: IndexedStack(
index: _selectedIndex, index: _selectedIndex,
children: _screens, children: screens,
), ),
bottomNavigationBar: Container( bottomNavigationBar: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -47,7 +76,7 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
), ),
child: BottomNavigationBar( child: BottomNavigationBar(
currentIndex: _selectedIndex, currentIndex: _selectedIndex,
onTap: _onItemTapped, onTap: selectTab,
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,
backgroundColor: Theme.of(context).cardColor, backgroundColor: Theme.of(context).cardColor,
selectedItemColor: AppTheme.primaryColor, selectedItemColor: AppTheme.primaryColor,
+4 -2
View File
@@ -53,6 +53,8 @@ class AiExportService {
required double targetCenterY, required double targetCenterY,
required double targetRadius, required double targetRadius,
required List<Shot> shots, required List<Shot> shots,
int distanceMeters = 25,
String weaponName = 'Unknown',
String? apiUrl, String? apiUrl,
}) async { }) async {
try { try {
@@ -109,8 +111,8 @@ class AiExportService {
"device_info": deviceData, "device_info": deviceData,
"target_metadata": { "target_metadata": {
"type": targetType.name, "type": targetType.name,
"distance_meters": 25, // Default/placeholder "distance_meters": distanceMeters,
"weapon": "Unknown", // Default/placeholder "weapon": weaponName,
// The backend could extract exact width/height from the image. // The backend could extract exact width/height from the image.
}, },
"plotting": { "plotting": {
+426
View File
@@ -0,0 +1,426 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../data/models/maintenance.dart';
import '../data/models/session.dart';
import '../data/models/shot.dart';
import '../data/models/target_analysis.dart';
import '../data/models/weapon.dart';
import '../data/repositories/session_repository.dart';
import 'statistics_service.dart';
/// Résumé d'un fichier de sauvegarde, affiché avant de confirmer un import.
class BackupPreview {
final int sessionCount;
final int targetCount;
final int shotCount;
final int weaponCount;
final int maintenanceCount;
final bool hasImages;
final DateTime? exportedAt;
final Map<String, dynamic> raw;
const BackupPreview({
required this.sessionCount,
required this.targetCount,
required this.shotCount,
required this.weaponCount,
required this.maintenanceCount,
required this.hasImages,
required this.exportedAt,
required this.raw,
});
}
/// Résultat d'un import : ce qui a réellement été écrit en base.
class ImportResult {
final int sessions;
final int weapons;
final int maintenance;
final int images;
final List<String> errors;
const ImportResult({
required this.sessions,
required this.weapons,
required this.maintenance,
required this.images,
required this.errors,
});
}
/// Erreur « propre » d'import : message directement affichable à l'utilisateur.
class BackupFormatException implements Exception {
final String message;
BackupFormatException(this.message);
@override
String toString() => message;
}
/// Import / export de l'intégralité des données de l'app dans un fichier JSON :
/// sessions (avec cibles, impacts et calibration), armurerie (armes +
/// maintenance) et un instantané des statistiques calculées.
///
/// Les statistiques ne sont pas réimportées : elles sont recalculées à partir
/// des sessions. Elles figurent dans le fichier pour pouvoir être lues telles
/// quelles (analyse externe, IA, tableur).
class BackupService {
static const String formatId = 'impact.backup';
static const int formatVersion = 1;
final SessionRepository _repository;
final StatisticsService _statisticsService;
BackupService({
required SessionRepository repository,
StatisticsService? statisticsService,
}) : _repository = repository,
_statisticsService = statisticsService ?? StatisticsService();
// ---------------------------------------------------------------- EXPORT
/// Construit le fichier de sauvegarde et renvoie le fichier écrit dans le
/// dossier temporaire, prêt à être passé à la feuille de partage du système.
Future<File> exportToFile({bool includeImages = false}) async {
final json = await buildBackupJson(includeImages: includeImages);
final tempDir = await getTemporaryDirectory();
final file = File(p.join(tempDir.path, _buildFileName()));
await file.writeAsString(
const JsonEncoder.withIndent(' ').convert(json),
flush: true,
);
return file;
}
String _buildFileName() {
final now = DateTime.now();
String two(int v) => v.toString().padLeft(2, '0');
return 'impact_sauvegarde_${now.year}-${two(now.month)}-${two(now.day)}'
'_${two(now.hour)}${two(now.minute)}.json';
}
@visibleForTesting
Future<Map<String, dynamic>> buildBackupJson({
bool includeImages = false,
}) async {
final sessions = await _repository.getAllSessions();
final weapons = await _repository.getWeapons();
final maintenance = await _repository.getAllMaintenance();
// Maintenance regroupée par arme : une arme reste autonome dans le fichier.
final maintenanceByWeapon = <String, List<MaintenanceEntry>>{};
for (final entry in maintenance) {
(maintenanceByWeapon[entry.weaponId] ??= []).add(entry);
}
var totalTargets = 0;
var totalShots = 0;
final sessionsJson = <Map<String, dynamic>>[];
for (final session in sessions) {
final analysesJson = <Map<String, dynamic>>[];
for (final analysis in session.analyses) {
totalTargets++;
totalShots += analysis.shots.length;
analysesJson.add(await _analysisToJson(analysis, includeImages));
}
sessionsJson.add({
...session.toMap(),
'analyses': analysesJson,
});
}
return {
'format': formatId,
'version': formatVersion,
'app': 'bully',
'exported_at': DateTime.now().toIso8601String(),
'includes_images': includeImages,
'counts': {
'sessions': sessions.length,
'targets': totalTargets,
'shots': totalShots,
'weapons': weapons.length,
'maintenance': maintenance.length,
},
'statistics': _statisticsToJson(sessions),
'weapons': weapons
.map((w) => {
...w.toMap(),
'maintenance': (maintenanceByWeapon[w.id] ?? [])
.map((e) => e.toMap())
.toList(),
})
.toList(),
'sessions': sessionsJson,
// Maintenance orpheline (arme supprimée) : conservée pour ne rien perdre.
'orphan_maintenance': maintenance
.where((e) => !weapons.any((w) => w.id == e.weaponId))
.map((e) => e.toMap())
.toList(),
};
}
Future<Map<String, dynamic>> _analysisToJson(
TargetAnalysis analysis,
bool includeImages,
) async {
final json = <String, dynamic>{
...analysis.toMap(),
'shots': analysis.shots.map((s) => s.toMap()).toList(),
};
if (includeImages) {
try {
final file = File(analysis.imagePath);
if (await file.exists()) {
json['image_extension'] = p.extension(analysis.imagePath);
json['image_base64'] = base64Encode(await file.readAsBytes());
}
} catch (e) {
// Une photo illisible ne doit pas faire échouer toute la sauvegarde.
debugPrint('Sauvegarde : image ignorée (${analysis.id}) : $e');
}
}
return json;
}
Map<String, dynamic> _statisticsToJson(List<Session> sessions) {
final stats = _statisticsService.calculateStatistics(
sessions,
period: StatsPeriod.all,
);
return {
'total_shots': stats.totalShots,
'total_score': stats.totalScore,
'average_score': stats.avgScore,
'max_score': stats.maxScore,
'min_score': stats.minScore,
'precision': {
'avg_distance_from_center': stats.precision.avgDistanceFromCenter,
'grouping_diameter': stats.precision.groupingDiameter,
'precision_score': stats.precision.precisionScore,
'consistency_score': stats.precision.consistencyScore,
},
'std_dev': {
'x': stats.stdDev.stdDevX,
'y': stats.stdDev.stdDevY,
'radial': stats.stdDev.stdDevRadial,
'score': stats.stdDev.stdDevScore,
'mean_x': stats.stdDev.meanX,
'mean_y': stats.stdDev.meanY,
'mean_score': stats.stdDev.meanScore,
},
'regional': {
'quadrants': stats.regional.quadrantDistribution,
'sectors': stats.regional.sectorDistribution,
'dominant_direction': stats.regional.dominantDirection,
'bias_x': stats.regional.biasX,
'bias_y': stats.regional.biasY,
},
'heat_map': {
'grid_size': stats.heatMap.gridSize,
'max_shots_in_zone': stats.heatMap.maxShotsInZone,
'zones': [
for (final row in stats.heatMap.zones)
for (final zone in row)
{
'row': zone.row,
'col': zone.col,
'shot_count': zone.shotCount,
'intensity': zone.intensity,
'avg_score': zone.avgScore,
},
],
},
};
}
// ---------------------------------------------------------------- IMPORT
/// Lit et valide un fichier de sauvegarde sans rien écrire en base.
Future<BackupPreview> readBackup(File file) async {
late final dynamic decoded;
try {
decoded = jsonDecode(await file.readAsString());
} catch (e) {
throw BackupFormatException(
'Fichier illisible : ce n\'est pas un JSON valide.',
);
}
if (decoded is! Map<String, dynamic>) {
throw BackupFormatException('Fichier illisible : format inattendu.');
}
if (decoded['format'] != formatId) {
throw BackupFormatException(
'Ce fichier n\'est pas une sauvegarde IMPACT.',
);
}
final version = (decoded['version'] as num?)?.toInt() ?? 0;
if (version > formatVersion) {
throw BackupFormatException(
'Sauvegarde créée par une version plus récente de l\'application '
'(format $version). Mettez l\'app à jour pour l\'importer.',
);
}
final sessions = _asList(decoded['sessions']);
final weapons = _asList(decoded['weapons']);
var targets = 0;
var shots = 0;
var hasImages = false;
for (final session in sessions) {
for (final analysis in _asList(session['analyses'])) {
targets++;
shots += _asList(analysis['shots']).length;
if (analysis['image_base64'] != null) hasImages = true;
}
}
var maintenance = _asList(decoded['orphan_maintenance']).length;
for (final weapon in weapons) {
maintenance += _asList(weapon['maintenance']).length;
}
return BackupPreview(
sessionCount: sessions.length,
targetCount: targets,
shotCount: shots,
weaponCount: weapons.length,
maintenanceCount: maintenance,
hasImages: hasImages,
exportedAt: DateTime.tryParse(decoded['exported_at'] as String? ?? ''),
raw: decoded,
);
}
/// Écrit en base le contenu d'une sauvegarde déjà lue par [readBackup].
///
/// Fusion : les entrées existantes portant le même identifiant sont
/// remplacées, les autres sont conservées. Réimporter deux fois la même
/// sauvegarde ne crée donc pas de doublons.
Future<ImportResult> applyBackup(BackupPreview preview) async {
final errors = <String>[];
var importedSessions = 0;
var importedWeapons = 0;
var importedMaintenance = 0;
var importedImages = 0;
// 1. Armurerie d'abord : les sessions y font référence par weapon_id.
for (final weaponJson in _asList(preview.raw['weapons'])) {
try {
await _repository.saveWeapon(Weapon.fromMap(weaponJson));
importedWeapons++;
} catch (e) {
errors.add('Arme ignorée : $e');
continue;
}
for (final entryJson in _asList(weaponJson['maintenance'])) {
try {
await _repository.saveMaintenanceEntry(
MaintenanceEntry.fromMap(entryJson),
);
importedMaintenance++;
} catch (e) {
errors.add('Entretien ignoré : $e');
}
}
}
// 2. Sessions, cibles et impacts.
for (final sessionJson in _asList(preview.raw['sessions'])) {
try {
final analyses = <TargetAnalysis>[];
for (final analysisJson in _asList(sessionJson['analyses'])) {
final shots = _asList(analysisJson['shots'])
.map((s) => Shot.fromMap(s))
.toList();
var map = _normalizeAnalysis(analysisJson);
final imagePath = await _restoreImage(analysisJson);
if (imagePath != null) {
map = {...map, 'image_path': imagePath};
importedImages++;
}
analyses.add(TargetAnalysis.fromMap(map, shots));
}
await _repository.saveSession(Session.fromMap(sessionJson, analyses));
importedSessions++;
} catch (e) {
errors.add('Session ignorée : $e');
}
}
// 3. Maintenance dont l'arme a été supprimée avant l'export.
for (final entryJson in _asList(preview.raw['orphan_maintenance'])) {
try {
await _repository.saveMaintenanceEntry(
MaintenanceEntry.fromMap(entryJson),
);
importedMaintenance++;
} catch (e) {
errors.add('Entretien ignoré : $e');
}
}
return ImportResult(
sessions: importedSessions,
weapons: importedWeapons,
maintenance: importedMaintenance,
images: importedImages,
errors: errors,
);
}
/// Recrée la photo de cible si la sauvegarde l'embarque, et renvoie son
/// nouveau chemin local. `null` si la sauvegarde est sans photos : le chemin
/// d'origine est alors conservé (l'app affiche un placeholder s'il est mort).
Future<String?> _restoreImage(Map<String, dynamic> analysisJson) async {
final encoded = analysisJson['image_base64'] as String?;
if (encoded == null || encoded.isEmpty) return null;
try {
final bytes = base64Decode(encoded);
final extension = analysisJson['image_extension'] as String? ?? '.jpg';
return await _repository.saveImageBytes(bytes, extension);
} catch (e) {
debugPrint('Import : image ignorée : $e');
return null;
}
}
/// JSON ne distingue pas 1 de 1.0 : une valeur ronde relue devient un `int`
/// et casse les `as double?` des modèles. On reforce donc les doubles.
Map<String, dynamic> _normalizeAnalysis(Map<String, dynamic> json) {
const doubleKeys = [
'grouping_diameter',
'grouping_center_x',
'grouping_center_y',
'target_center_x',
'target_center_y',
'target_radius',
];
final map = Map<String, dynamic>.from(json);
for (final key in doubleKeys) {
map[key] = (map[key] as num?)?.toDouble();
}
return map;
}
List<Map<String, dynamic>> _asList(dynamic value) {
if (value is! List) return const [];
return value.whereType<Map<String, dynamic>>().toList();
}
}
File diff suppressed because it is too large Load Diff
+253 -63
View File
@@ -1,27 +1,14 @@
/// Service de recadrage d'images. import 'dart:isolate'; // AJOUT
///
/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder
/// dans un fichier temporaire pour utilisation ultérieure.
library;
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
/// Représente une zone de recadrage normalisée (0.0 à 1.0)
class CropRect { class CropRect {
/// Position X du coin supérieur gauche (0.0 à 1.0)
final double x; final double x;
/// Position Y du coin supérieur gauche (0.0 à 1.0)
final double y; final double y;
/// Largeur de la zone (0.0 à 1.0)
final double width; final double width;
/// Hauteur de la zone (0.0 à 1.0)
final double height; final double height;
const CropRect({ const CropRect({
@@ -35,47 +22,59 @@ class CropRect {
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)'; String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
} }
/// Service pour recadrer les images // AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable)
class ImageCropService { class _CropParams {
final Uuid _uuid = const Uuid(); final String sourcePath;
final double cropX;
final double cropY;
final double cropWidth;
final double cropHeight;
final double rotationDegrees;
final int outputSize;
final String outputPath;
/// Taille de sortie maximale pour les images recadrées _CropParams({
static const int maxOutputSize = 1024; required this.sourcePath,
required this.cropX,
required this.cropY,
required this.cropWidth,
required this.cropHeight,
required this.rotationDegrees,
required this.outputSize,
required this.outputPath,
});
}
/// Recadre une image en format carré // AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static)
/// void _cropIsolateEntry(_CropParams params) {
/// [sourcePath] - Chemin vers l'image source final file = File(params.sourcePath);
/// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0) final bytes = file.readAsBytesSync();
/// [outputSize] - Taille de sortie en pixels (carré) img.Image? originalImage = img.decodeImage(bytes);
///
/// Retourne le chemin vers l'image recadrée dans le dossier temporaire
Future<String> cropToSquare(
String sourcePath,
CropRect cropRect, {
int outputSize = maxOutputSize,
}) async {
// Charger l'image source
final file = File(sourcePath);
final bytes = await file.readAsBytes();
final originalImage = img.decodeImage(bytes);
if (originalImage == null) { if (originalImage == null) {
throw Exception('Impossible de décoder l\'image: $sourcePath'); throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
} }
// Calculer les coordonnées en pixels // Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
final srcX = (cropRect.x * originalImage.width).round(); if (params.rotationDegrees != 0.0) {
final srcY = (cropRect.y * originalImage.height).round(); originalImage = img.copyRotate(
final srcWidth = (cropRect.width * originalImage.width).round(); originalImage,
final srcHeight = (cropRect.height * originalImage.height).round(); angle: params.rotationDegrees,
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
);
}
// Recadrage
final srcX = (params.cropX * originalImage.width).round();
final srcY = (params.cropY * originalImage.height).round();
final srcWidth = (params.cropWidth * originalImage.width).round();
final srcHeight = (params.cropHeight * originalImage.height).round();
// S'assurer que les dimensions sont valides
final clampedX = srcX.clamp(0, originalImage.width - 1); final clampedX = srcX.clamp(0, originalImage.width - 1);
final clampedY = srcY.clamp(0, originalImage.height - 1); final clampedY = srcY.clamp(0, originalImage.height - 1);
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX); final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY); final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
// Recadrer l'image
img.Image cropped = img.copyCrop( img.Image cropped = img.copyCrop(
originalImage, originalImage,
x: clampedX, x: clampedX,
@@ -84,48 +83,207 @@ class ImageCropService {
height: clampedHeight, height: clampedHeight,
); );
// Redimensionner à la taille de sortie si nécessaire // Redimensionnement — LINEAR au lieu de CUBIC
if (cropped.width != outputSize || cropped.height != outputSize) { if (cropped.width != params.outputSize || cropped.height != params.outputSize) {
cropped = img.copyResize( cropped = img.copyResize(
cropped, cropped,
width: outputSize, width: params.outputSize,
height: outputSize, height: params.outputSize,
interpolation: img.Interpolation.cubic, interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
); );
} }
// Sauvegarder dans le dossier temporaire // Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable)
final tempDir = await getTemporaryDirectory(); final outputFile = File(params.outputPath);
final fileName = 'cropped_${_uuid.v4()}.jpg'; outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
final outputPath = '${tempDir.path}/$fileName'; }
final outputFile = File(outputPath); // AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir)
await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90)); class _ViewportCropParams {
final String sourcePath;
final double offsetDx;
final double offsetDy;
final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain)
final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran
final double zoomScale; // zoom utilisateur : sert UNIQUEMENT au pointage
final double rotationDegrees;
final int outputSize;
final String outputPath;
_ViewportCropParams({
required this.sourcePath,
required this.offsetDx,
required this.offsetDy,
required this.displayPerSourcePx,
required this.cropSizeDisplay,
required this.zoomScale,
required this.rotationDegrees,
required this.outputSize,
required this.outputPath,
});
}
// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation
// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute
// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur
// l'a placée, même collée à un bord (aucun recentrage forcé).
void _viewportCropIsolateEntry(_ViewportCropParams p) {
final bytes = File(p.sourcePath).readAsBytesSync();
final img.Image? src = img.decodeImage(bytes);
if (src == null) {
throw Exception('Impossible de décoder l\'image: ${p.sourcePath}');
}
// Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels.
img.Image rotated = src;
if (p.rotationDegrees != 0.0) {
rotated = img.copyRotate(
src,
angle: p.rotationDegrees,
interpolation: img.Interpolation.linear,
);
}
final double f = p.displayPerSourcePx;
// Côté de la fenêtre de visée en pixels source. On utilise f SEUL (pas le
// zoom) → le champ de vision capturé est toujours celui de l'image non
// zoomée : le zoom n'est donc PAS pris en compte dans le rendu de sortie.
final int side = math.max(1, (p.cropSizeDisplay / f).round());
// Centre de la fenêtre de visée, en pixels de l'image (rotated).
// La translation écran s'applique APRÈS le zoom, donc un déplacement écran
// `offset` vaut `offset / (f * zoom)` pixels source. On inclut donc le zoom
// ICI (uniquement pour le pointage) afin que la croix vise le même point
// quel que soit le niveau de zoom.
final double fz = f * p.zoomScale;
final double cropCenterX = rotated.width / 2 - p.offsetDx / fz;
final double cropCenterY = rotated.height / 2 - p.offsetDy / fz;
final int srcX = (cropCenterX - side / 2).round();
final int srcY = (cropCenterY - side / 2).round();
// Toile carrée NOIRE opaque (numChannels 3 → pixels initialisés à 0 = noir).
final out = img.Image(width: side, height: side, numChannels: 3);
// Intersection de la fenêtre de visée avec l'image réelle. Tout ce qui
// déborde reste noir (padding) → aucun recentrage forcé.
final int vx0 = math.max(0, srcX);
final int vy0 = math.max(0, srcY);
final int vx1 = math.min(rotated.width, srcX + side);
final int vy1 = math.min(rotated.height, srcY + side);
final int vw = vx1 - vx0;
final int vh = vy1 - vy0;
if (vw > 0 && vh > 0) {
final region = img.copyCrop(rotated, x: vx0, y: vy0, width: vw, height: vh);
// dstW/dstH EXPLICITES = taille de la région → AUCUN redimensionnement
// (sinon compositeImage étire la source pour remplir la destination).
img.compositeImage(
out,
region,
dstX: vx0 - srcX,
dstY: vy0 - srcY,
dstW: vw,
dstH: vh,
);
}
img.Image result = out;
if (side != p.outputSize) {
result = img.copyResize(
out,
width: p.outputSize,
height: p.outputSize,
interpolation: img.Interpolation.linear,
);
}
File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85));
}
class ImageCropService {
final Uuid _uuid = const Uuid();
static const int maxOutputSize = 1024;
/// Découpe carrée calée sur la fenêtre de visée de l'écran de centrage.
/// Le décalage (pan) et la rotation sont respectés, le zoom est ignoré, et
/// tout débordement hors de l'image est rempli en noir (jamais de recentrage).
///
/// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran.
/// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain).
/// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran.
Future<String> cropViewport({
required String sourcePath,
required double offsetDx,
required double offsetDy,
required double displayPerSourcePx,
required double cropSizeDisplay,
double zoomScale = 1.0,
double rotationDegrees = 0.0,
int outputSize = maxOutputSize,
}) async {
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
final params = _ViewportCropParams(
sourcePath: sourcePath,
offsetDx: offsetDx,
offsetDy: offsetDy,
displayPerSourcePx: displayPerSourcePx,
cropSizeDisplay: cropSizeDisplay,
zoomScale: zoomScale <= 0 ? 1.0 : zoomScale,
rotationDegrees: rotationDegrees,
outputSize: outputSize,
outputPath: outputPath,
);
// Traitement lourd dans un Isolate → thread UI fluide.
await Isolate.run(() => _viewportCropIsolateEntry(params));
return outputPath;
}
Future<String> cropToSquare(
String sourcePath,
CropRect cropRect, {
double rotationDegrees = 0.0,
int outputSize = maxOutputSize,
}) async {
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
final params = _CropParams(
sourcePath: sourcePath,
cropX: cropRect.x,
cropY: cropRect.y,
cropWidth: cropRect.width,
cropHeight: cropRect.height,
rotationDegrees: rotationDegrees,
outputSize: outputSize,
outputPath: outputPath,
);
// OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé
// → le thread UI reste fluide pendant le calcul
await Isolate.run(() => _cropIsolateEntry(params));
return outputPath; return outputPath;
} }
/// Calcule la zone de recadrage carrée maximale centrée sur l'image /// Calcule la zone de recadrage carrée maximale centrée sur l'image
///
/// [imageWidth] - Largeur de l'image en pixels
/// [imageHeight] - Hauteur de l'image en pixels
///
/// Retourne un CropRect normalisé pour un carré centré
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) { CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
final aspectRatio = imageWidth / imageHeight; final aspectRatio = imageWidth / imageHeight;
if (aspectRatio > 1) { if (aspectRatio > 1) {
// Image plus large que haute - centrer horizontalement
final squareWidth = imageHeight / imageWidth; final squareWidth = imageHeight / imageWidth;
final x = (1 - squareWidth) / 2; final x = (1 - squareWidth) / 2;
return CropRect(x: x, y: 0, width: squareWidth, height: 1); return CropRect(x: x, y: 0, width: squareWidth, height: 1);
} else if (aspectRatio < 1) { } else if (aspectRatio < 1) {
// Image plus haute que large - centrer verticalement
final squareHeight = imageWidth / imageHeight; final squareHeight = imageWidth / imageHeight;
final y = (1 - squareHeight) / 2; final y = (1 - squareHeight) / 2;
return CropRect(x: 0, y: y, width: 1, height: squareHeight); return CropRect(x: 0, y: y, width: 1, height: squareHeight);
} else { } else {
// Déjà carré
return const CropRect(x: 0, y: 0, width: 1, height: 1); return const CropRect(x: 0, y: 0, width: 1, height: 1);
} }
} }
@@ -147,7 +305,39 @@ class ImageCropService {
} }
} }
} catch (e) { } catch (e) {
// Ignorer les erreurs de nettoyage // Ignorer les erreurs
} }
} }
/// Coupe automatiquement une photo brute de l'appareil photo en carré
/// calé sur la position haute du viseur de l'écran.
Future<String> cropRawCameraToSquare(String sourcePath) async {
final file = File(sourcePath);
final bytes = await file.readAsBytes();
final originalImage = img.decodeImage(bytes);
if (originalImage == null) return sourcePath;
final int imgW = originalImage.width;
final int imgH = originalImage.height;
final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round();
final int x = ((imgW - cropSizePixels) / 2).round();
final int y = ((imgH - cropSizePixels) / 2).round();
final squareImage = img.copyCrop(
originalImage,
x: x.clamp(0, imgW - 1),
y: y.clamp(0, imgH - 1),
width: cropSizePixels,
height: cropSizePixels,
);
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg';
await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85));
return outputPath;
}
} }
File diff suppressed because it is too large Load Diff
@@ -1,228 +0,0 @@
/// Service de détection d'impacts utilisant OpenCV.
library;
import 'dart:math' as math;
import 'package:opencv_dart/opencv_dart.dart' as cv;
/// Paramètres de détection d'impacts OpenCV
class OpenCVDetectionSettings {
/// Seuil Canny bas pour la détection de contours
final double cannyThreshold1;
/// Seuil Canny haut pour la détection de contours
final double cannyThreshold2;
/// Distance minimale entre les centres des cercles détectés
final double minDist;
/// Paramètre 1 de HoughCircles (seuil Canny interne)
final double param1;
/// Paramètre 2 de HoughCircles (seuil d'accumulation)
final double param2;
/// Rayon minimum des cercles en pixels
final int minRadius;
/// Rayon maximum des cercles en pixels
final int maxRadius;
/// Taille du flou gaussien (doit être impair)
final int blurSize;
/// Utiliser la détection de contours en plus de Hough
final bool useContourDetection;
/// Circularité minimale pour la détection par contours (0-1)
final double minCircularity;
/// Surface minimale des contours
final double minContourArea;
/// Surface maximale des contours
final double maxContourArea;
const OpenCVDetectionSettings({
this.cannyThreshold1 = 50,
this.cannyThreshold2 = 150,
this.minDist = 20,
this.param1 = 100,
this.param2 = 30,
this.minRadius = 5,
this.maxRadius = 50,
this.blurSize = 5,
this.useContourDetection = true,
this.minCircularity = 0.6,
this.minContourArea = 50,
this.maxContourArea = 5000,
});
}
/// Résultat de détection d'impact
class OpenCVDetectedImpact {
/// Position X normalisée (0-1)
final double x;
/// Position Y normalisée (0-1)
final double y;
/// Rayon en pixels
final double radius;
/// Score de confiance (0-1)
final double confidence;
/// Méthode de détection utilisée
final String method;
const OpenCVDetectedImpact({
required this.x,
required this.y,
required this.radius,
this.confidence = 1.0,
this.method = 'unknown',
});
}
/// Service de détection d'impacts utilisant OpenCV
class OpenCVImpactDetectionService {
/// Détecte les impacts dans une image en utilisant OpenCV
List<OpenCVDetectedImpact> detectImpacts(
String imagePath, {
OpenCVDetectionSettings settings = const OpenCVDetectionSettings(),
}) {
try {
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) return [];
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply blur to reduce noise
final blurKSize = (settings.blurSize, settings.blurSize);
final blurred = cv.gaussianBlur(gray, blurKSize, 2, sigmaY: 2);
final List<OpenCVDetectedImpact> detectedImpacts = [];
final circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
settings.minDist,
param1: settings.param1,
param2: settings.param2,
minRadius: settings.minRadius,
maxRadius: settings.maxRadius,
);
if (circles.rows > 0 && circles.cols > 0) {
// Mat shape: (1, N, 3) usually for HoughCircles (CV_32FC3)
// We use at<Vec3f> directly.
for (int i = 0; i < circles.cols; i++) {
final vec = circles.at<cv.Vec3f>(0, i);
final x = vec.val1;
final y = vec.val2;
final r = vec.val3;
detectedImpacts.add(
OpenCVDetectedImpact(
x: x / img.cols,
y: y / img.rows,
radius: r,
confidence: 0.8,
method: 'hough',
),
);
}
}
// 2. Contour Detection (if enabled)
if (settings.useContourDetection) {
// Canny edge detection
final edges = cv.canny(
blurred,
settings.cannyThreshold1,
settings.cannyThreshold2,
);
// Find contours
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
// hierarchy is $2
for (int i = 0; i < contours.length; i++) {
final contour = contours[i];
// Filter by area
final area = cv.contourArea(contour);
if (area < settings.minContourArea ||
area > settings.maxContourArea) {
continue;
}
// Filter by circularity
final perimeter = cv.arcLength(contour, true);
if (perimeter == 0) continue;
final circularity = 4 * math.pi * area / (perimeter * perimeter);
if (circularity < settings.minCircularity) continue;
// Get bounding circle
final enclosingCircle = cv.minEnclosingCircle(contour);
final center = enclosingCircle.$1;
final radius = enclosingCircle.$2;
// Avoid duplicates (simple distance check against Hough results)
bool isDuplicate = false;
for (final existing in detectedImpacts) {
final dx = existing.x * img.cols - center.x;
final dy = existing.y * img.rows - center.y;
final dist = math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
detectedImpacts.add(
OpenCVDetectedImpact(
x: center.x / img.cols,
y: center.y / img.rows,
radius: radius,
confidence: circularity, // Use circularity as confidence
method: 'contour',
),
);
}
}
}
return detectedImpacts;
} catch (e) {
// print('OpenCV Error: $e');
return [];
}
}
/// Détecte les impacts en utilisant une image de référence
List<OpenCVDetectedImpact> detectFromReferences(
String imagePath,
List<({double x, double y})> referencePoints, {
double tolerance = 2.0,
}) {
// Basic implementation: use average color/brightness of reference points
// This is a placeholder for a more complex template matching or feature matching
// For now, we can just run the standard detection but filter results
// based on properties of the reference points (e.g. size/radius if we had it).
// Returning standard detection for now to enable the feature.
return detectImpacts(imagePath);
}
}
+26 -25
View File
@@ -26,23 +26,34 @@ class TargetDetectionResult {
class OpenCVTargetService { class OpenCVTargetService {
/// Detect the main target (center and radius) from an image file /// Detect the main target (center and radius) from an image file
///
/// IMPORTANT : les Mat OpenCV sont de la mémoire NATIVE, invisible pour le
/// garbage collector Dart. Cette méthode est appelée en boucle (~1 s)
/// pendant l'aperçu caméra : sans dispose() explicite dans le finally, la
/// mémoire native grimpe en continu tant que l'utilisateur vise.
Future<TargetDetectionResult> detectTarget(String imagePath) async { Future<TargetDetectionResult> detectTarget(String imagePath) async {
cv.Mat? img;
cv.Mat? gray;
cv.Mat? blurred;
cv.Mat? circles;
cv.Mat? looseCircles;
try { try {
// Read image // Read image
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR); img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) { if (img.isEmpty) {
return TargetDetectionResult.failure(); return TargetDetectionResult.failure();
} }
// Convert to grayscale // Convert to grayscale
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY); gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply Gaussian blur to reduce noise // Apply Gaussian blur to reduce noise
final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2); blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
// Detect circles using Hough Transform // Detect circles using Hough Transform.
// Parameters need to be tuned for the specific target type // HoughCircles returns a Mat of shape (1, N) of Vec3f (x, y, r).
final circles = cv.HoughCircles( circles = cv.HoughCircles(
blurred, blurred,
cv.HOUGH_GRADIENT, cv.HOUGH_GRADIENT,
1, // dp 1, // dp
@@ -55,26 +66,9 @@ class OpenCVTargetService {
maxRadius: img.cols ~/ 2, maxRadius: img.cols ~/ 2,
); );
// HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles.
// In opencv_dart, we cannot iterate easily.
// However, we can access data via pointer if needed, or check if Vec3f is supported.
// Given the user report, `at<Vec3f>` likely failed compilation or runtime.
// Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...).
// Or use `at<double>` carefully.
// Better yet: try to use `circles.data` if available, but it returns a Pointer.
// Let's stick to `at` but use `double` and manual offset if Vec3f fails.
// actually, let's try to trust `at<double>` for flattened access OR `at<Vec3f>`.
// NOTE: `at<Vec3f>` was reported as "method at not defined for VecPoint2f" earlier, NOT for Mat.
// The user error was for `VecPoint2f`. `Mat` definitely has `at`.
// BUT `VecPoint2f` is a List-like structure in Dart wrapper.
// usage of `at` on `VecPoint2f` was the error.
// Here `circles` IS A MAT. So `at` IS defined.
// However, to be safe and robust, and to implement clustering...
if (circles.isEmpty) { if (circles.isEmpty) {
// Try with different parameters if first attempt fails (more lenient) // Try with different parameters if first attempt fails (more lenient)
final looseCircles = cv.HoughCircles( looseCircles = cv.HoughCircles(
blurred, blurred,
cv.HOUGH_GRADIENT, cv.HOUGH_GRADIENT,
1, 1,
@@ -93,8 +87,15 @@ class OpenCVTargetService {
return _findBestConcentricCircles(circles, img.cols, img.rows); return _findBestConcentricCircles(circles, img.cols, img.rows);
} catch (e) { } catch (e) {
// print('Error detecting target with OpenCV: $e');
return TargetDetectionResult.failure(); return TargetDetectionResult.failure();
} finally {
// _findBestConcentricCircles a déjà extrait les données dans des listes
// Dart avant qu'on arrive ici : libérer les Mat est donc toujours sûr.
img?.dispose();
gray?.dispose();
blurred?.dispose();
circles?.dispose();
looseCircles?.dispose();
} }
} }
+161
View File
@@ -0,0 +1,161 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:sensors_plus/sensors_plus.dart';
/// Statut de parallélisme retourné en temps réel.
enum ParallelismStatus {
/// Pas encore de données capteur disponibles.
unknown,
/// L'appareil est bien parallèle à la cible.
aligned,
/// L'appareil est trop incliné.
misaligned,
}
/// Données de parallélisme calculées à chaque frame capteur.
class ParallelismData {
final ParallelismStatus status;
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
final double pitchDegrees;
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
final double rollDegrees;
const ParallelismData({
required this.status,
required this.pitchDegrees,
required this.rollDegrees,
});
bool get isAligned => status == ParallelismStatus.aligned;
@override
String toString() =>
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
}
/// Service de détection du parallélisme par accéléromètre.
///
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
///
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
/// État actuel = aligned → passe à misaligned si angle > [misalignThreshold]
///
/// Cela crée une "zone de confort" entre les deux seuils où le statut
/// ne change pas — le vert reste vert même si la main tremble légèrement.
///
/// Valeurs par défaut recommandées :
/// alignThreshold = 15° (seuil d'entrée dans le vert — assez souple)
/// misalignThreshold = 22° (seuil de sortie du vert — tolérant au tremblement)
class ParallelismService {
/// Angle max pour passer de misaligned → aligned.
final double alignThreshold;
/// Angle à dépasser pour passer de aligned → misaligned.
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
final double misalignThreshold;
StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast();
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
ParallelismService({
this.alignThreshold = 25.0,
this.misalignThreshold = 32.0,
}) : assert(
misalignThreshold > alignThreshold,
'misalignThreshold doit être supérieur à alignThreshold',
);
Stream<ParallelismData> get stream => _controller.stream;
void start() {
if (_subscription != null) return;
_subscription = accelerometerEventStream(
samplingPeriod: SensorInterval.normalInterval, // ~50 ms
).listen(
_onAccelerometerEvent,
onError: (_) {
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
if (!_controller.isClosed) {
_currentStatus = ParallelismStatus.unknown;
_controller.add(const ParallelismData(
status: ParallelismStatus.unknown,
pitchDegrees: 0,
rollDegrees: 0,
));
}
},
);
}
void stop() {
_subscription?.cancel();
_subscription = null;
_currentStatus = ParallelismStatus.unknown;
}
void dispose() {
stop();
_controller.close();
}
void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return;
final double gx = event.x;
final double gy = event.y;
final double gz = event.z;
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
if (magnitude < 1.0) return; // Données aberrantes
// Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude;
final double nz = gz / magnitude;
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
// Le critère de couleur = le PIRE des deux angles affichés à l'écran.
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert.
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs());
// ── Hystérésis ──────────────────────────────────────────────────────────
// Premier appel : on décide selon alignThreshold uniquement
if (_currentStatus == ParallelismStatus.unknown) {
_currentStatus = worstAngle <= alignThreshold
? ParallelismStatus.aligned
: ParallelismStatus.misaligned;
}
// Déjà misaligned → devient aligned seulement si on passe sous alignThreshold
else if (_currentStatus == ParallelismStatus.misaligned) {
if (worstAngle <= alignThreshold) {
_currentStatus = ParallelismStatus.aligned;
}
}
// Déjà aligned → devient misaligned seulement si on dépasse misalignThreshold
else if (_currentStatus == ParallelismStatus.aligned) {
if (worstAngle > misalignThreshold) {
_currentStatus = ParallelismStatus.misaligned;
}
// Entre alignThreshold et misalignThreshold → on garde le vert, on ne change rien
}
// ────────────────────────────────────────────────────────────────────────
_controller.add(ParallelismData(
status: _currentStatus,
pitchDegrees: pitchDeg,
rollDegrees: rollDeg,
));
}
}
-378
View File
@@ -1,378 +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;
class TargetDetectionResult {
final double centerX; // Relative (0-1)
final double centerY; // Relative (0-1)
final double radius; // Relative (0-1)
final List<DetectedImpactResult> impacts;
final bool success;
final String? errorMessage;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
required this.impacts,
this.success = true,
this.errorMessage,
});
factory TargetDetectionResult.error(String message) {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
impacts: [],
success: false,
errorMessage: message,
);
}
}
class DetectedImpactResult {
final double x; // Relative (0-1)
final double y; // Relative (0-1)
final double radius; // Absolute pixels
final int suggestedScore;
DetectedImpactResult({
required this.x,
required this.y,
required this.radius,
required this.suggestedScore,
});
}
class TargetDetectionService {
final ImageProcessingService _imageProcessingService;
final OpenCVImpactDetectionService _opencvService;
TargetDetectionService({
ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
try {
// Detect main target
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
// Detect impacts
final impacts = _imageProcessingService.detectImpacts(imagePath);
// Convert impacts to relative coordinates and calculate scores
final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection: $e');
}
}
int _calculateConcentricScore(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones (10 zones)
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0; // Outside target
}
int _calculateSilhouetteScore(
double impactX,
double impactY,
double centerX,
double centerY,
) {
// For silhouettes, scoring is typically based on zones
// Head and center mass = 5, body = 4, lower = 3
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
// Check if within silhouette bounds (approximate)
if (dx > 0.15) return 0; // Too far left/right
// Vertical zones
if (dy < -0.25) return 5; // Head zone (top)
if (dy < 0.0) return 5; // Center mass (upper body)
if (dy < 0.15) return 4; // Body
if (dy < 0.35) return 3; // Lower body
return 0; // Outside target
}
/// Detect only impacts with custom settings (doesn't affect target position)
List<DetectedImpactResult> detectImpactsOnly(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactDetectionSettings settings,
) {
try {
// Detect impacts with custom settings
final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath,
settings,
);
// Convert impacts to relative coordinates and calculate scores
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
int _calculateConcentricScoreWithRings(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
int ringCount,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones based on ringCount
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) {
return 10 - i;
}
}
return 0; // Outside target
}
/// Analyze reference impacts to learn their characteristics
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references,
) {
return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
}
/// Detect impacts based on reference characteristics (calibrated detection)
List<DetectedImpactResult> detectImpactsFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactCharacteristics characteristics, {
double tolerance = 2.0,
}) {
try {
final impacts = _imageProcessingService.detectImpactsFromReferences(
imagePath,
characteristics,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
/// Détecte les impacts en utilisant OpenCV (Hough Circles + Contours)
///
/// Cette méthode utilise les algorithmes OpenCV pour une détection plus robuste:
/// - Transformation de Hough pour détecter les cercles
/// - Analyse de contours avec filtrage par circularité
List<DetectedImpactResult> detectImpactsWithOpenCV(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount, {
OpenCVDetectionSettings? settings,
}) {
try {
final impacts = _opencvService.detectImpacts(
imagePath,
settings: settings ?? const OpenCVDetectionSettings(),
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV: $e');
return [];
}
}
/// Détecte les impacts avec OpenCV en utilisant des références
///
/// Analyse les impacts de référence pour apprendre leurs caractéristiques
/// puis détecte les impacts similaires dans l'image.
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
List<ReferenceImpact> references, {
double tolerance = 2.0,
}) {
try {
// Convertir les références au format OpenCV
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
final impacts = _opencvService.detectFromReferences(
imagePath,
refPoints,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV depuis références: $e');
return [];
}
}
}
@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
} }
+1
View File
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux file_selector_linux
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -7,12 +7,14 @@ import Foundation
import device_info_plus import device_info_plus
import file_selector_macos import file_selector_macos
import share_plus
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
} }
+157 -21
View File
@@ -33,6 +33,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
camera:
dependency: "direct main"
description:
name: camera
sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777"
url: "https://pub.dev"
source: hosted
version: "0.12.0+1"
camera_android_camerax:
dependency: transitive
description:
name: camera_android_camerax
sha256: b5064cf25a2787d122d0bf12e77c7b1033a2b983d0730e3091f770ee376efde5
url: "https://pub.dev"
source: hosted
version: "0.7.2"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d"
url: "https://pub.dev"
source: hosted
version: "0.10.1"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
url: "https://pub.dev"
source: hosted
version: "0.3.5+3"
change_case: change_case:
dependency: transitive dependency: transitive
description: description:
@@ -45,10 +85,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -161,6 +201,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_selector:
dependency: "direct main"
description:
name: file_selector
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
url: "https://pub.dev"
source: hosted
version: "1.1.0"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed"
url: "https://pub.dev"
source: hosted
version: "0.5.2+6"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
url: "https://pub.dev"
source: hosted
version: "0.5.3+5"
file_selector_linux: file_selector_linux:
dependency: transitive dependency: transitive
description: description:
@@ -185,6 +249,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.0" version: "2.7.0"
file_selector_web:
dependency: transitive
description:
name: file_selector_web
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_windows: file_selector_windows:
dependency: transitive dependency: transitive
description: description:
@@ -253,14 +325,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
google_mlkit_document_scanner:
dependency: "direct main"
description:
name: google_mlkit_document_scanner
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -425,26 +489,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -661,6 +725,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" version: "0.6.0"
sensors_plus:
dependency: "direct main"
description:
name: sensors_plus
sha256: "8e7fa79b4940442bb595bfc0ee9da4af5a22a0fe6ebacc74998245ee9496a82d"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
sensors_plus_platform_interface:
dependency: transitive
description:
name: sensors_plus_platform_interface
sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f
url: "https://pub.dev"
source: hosted
version: "1.2.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
url: "https://pub.dev"
source: hosted
version: "13.3.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -802,6 +898,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -814,10 +918,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: synchronized name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0+1" version: "3.4.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -830,10 +934,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.7"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -842,6 +946,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid: uuid:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -915,5 +1051,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.38.4"
+7 -11
View File
@@ -1,5 +1,5 @@
name: bully name: bully
description: "A new Flutter project." description: "Application d'analyse de cibles de tir : capture, centrage, plotting manuel des impacts, scores et statistiques."
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -36,12 +36,11 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
sensors_plus: ^4.0.2
opencv_dart: ^2.1.0 opencv_dart: ^2.1.0
# Image capture from camera/gallery # Image capture from camera/gallery
image_picker: ^1.2.1 image_picker: ^1.2.1
google_mlkit_document_scanner: ^0.2.0
# Local database for history # Local database for history
sqflite: ^2.3.2 sqflite: ^2.3.2
@@ -70,9 +69,9 @@ dependencies:
device_info_plus: ^13.1.0 device_info_plus: ^13.1.0
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
crypto: ^3.0.7 crypto: ^3.0.7
camera: ^0.12.0+1
# Machine Learning for YOLOv8 share_plus: ^13.3.0
# tflite_flutter: ^0.11.0 file_selector: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -96,11 +95,8 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: assets:
# assets: - assets/icons/
# - assets/models/yolov8n_32.tflite
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images
+372
View File
@@ -0,0 +1,372 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/data/models/maintenance.dart';
import 'package:bully/data/models/session.dart';
import 'package:bully/data/models/shot.dart';
import 'package:bully/data/models/target_analysis.dart';
import 'package:bully/data/models/target_type.dart';
import 'package:bully/data/models/weapon.dart';
import 'package:bully/data/repositories/session_repository.dart';
import 'package:bully/services/backup_service.dart';
/// Dépôt en mémoire : évite d'ouvrir une vraie base SQLite dans les tests.
class _FakeRepository extends SessionRepository {
final List<Session> sessions;
final List<Weapon> weapons;
final List<MaintenanceEntry> maintenance;
final List<List<int>> savedImages = [];
_FakeRepository({
List<Session>? sessions,
List<Weapon>? weapons,
List<MaintenanceEntry>? maintenance,
}) : sessions = sessions ?? [],
weapons = weapons ?? [],
maintenance = maintenance ?? [];
@override
Future<List<Session>> getAllSessions({int? limit, int? offset}) async =>
sessions;
@override
Future<List<Weapon>> getWeapons() async => weapons;
@override
Future<List<MaintenanceEntry>> getAllMaintenance() async => maintenance;
@override
Future<void> saveSession(Session session) async {
sessions.removeWhere((s) => s.id == session.id);
sessions.add(session);
}
@override
Future<void> saveWeapon(Weapon weapon) async {
weapons.removeWhere((w) => w.id == weapon.id);
weapons.add(weapon);
}
@override
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
maintenance.removeWhere((e) => e.id == entry.id);
maintenance.add(entry);
}
@override
Future<String> saveImageBytes(List<int> bytes, String extension) async {
savedImages.add(bytes);
return '/imported/image_${savedImages.length}$extension';
}
}
Session _session({String id = 's1', String weapon = 'Glock 17'}) {
return Session(
id: id,
weapon: weapon,
weaponId: 'w1',
maxShotsPerTarget: 5,
createdAt: DateTime(2026, 3, 14, 10, 30),
notes: 'Entraînement',
distance: 25,
analyses: [
TargetAnalysis(
id: '$id-a1',
sessionId: id,
targetType: TargetType.concentric,
imagePath: '/photos/$id.jpg',
totalScore: 18,
groupingDiameter: 0.12,
groupingCenterX: 0.5,
groupingCenterY: 0.48,
createdAt: DateTime(2026, 3, 14, 10, 35),
targetCenterX: 0.5,
targetCenterY: 0.5,
targetRadius: 0.4,
shots: [
Shot(id: '$id-t1', x: 0.5, y: 0.5, score: 10, analysisId: '$id-a1'),
Shot(id: '$id-t2', x: 0.55, y: 0.52, score: 8, analysisId: '$id-a1'),
],
),
],
);
}
Weapon _weapon() => Weapon(
id: 'w1',
name: 'Glock 17',
type: WeaponType.handgun,
caliber: '9mm',
magazineCount: 3,
magazineCapacity: 17,
createdAt: DateTime(2025, 1, 5),
optic: 'Point rouge',
customName: 'La bleue',
);
MaintenanceEntry _maintenance() => MaintenanceEntry(
id: 'm1',
weaponId: 'w1',
type: MaintenanceType.cleaning,
description: 'Nettoyage complet',
date: DateTime(2026, 2, 1),
roundsSinceLastMaintenance: 500,
);
/// Sérialise puis relit la sauvegarde comme le ferait un vrai fichier partagé.
Future<BackupPreview> _roundTrip(
Map<String, dynamic> json,
BackupService service,
) async {
final dir = await Directory.systemTemp.createTemp('impact_backup_test');
addTearDown(() => dir.delete(recursive: true));
final file = File('${dir.path}/backup.json');
await file.writeAsString(jsonEncode(json));
return service.readBackup(file);
}
void main() {
group('export', () {
test('la sauvegarde contient sessions, armurerie et stats', () async {
final source = _FakeRepository(
sessions: [_session()],
weapons: [_weapon()],
maintenance: [_maintenance()],
);
final json = await BackupService(repository: source).buildBackupJson();
expect(json['format'], BackupService.formatId);
expect(json['counts'], {
'sessions': 1,
'targets': 1,
'shots': 2,
'weapons': 1,
'maintenance': 1,
});
// Les stats sont recalculées et écrites telles quelles dans le fichier.
final stats = json['statistics'] as Map<String, dynamic>;
expect(stats['total_shots'], 2);
expect(stats['total_score'], 18);
// L'entretien voyage avec son arme.
final weapon = (json['weapons'] as List).single as Map<String, dynamic>;
expect((weapon['maintenance'] as List), hasLength(1));
expect(json['orphan_maintenance'], isEmpty);
});
test('sans photos, aucune image n\'est encodée', () async {
final source = _FakeRepository(sessions: [_session()]);
final json = await BackupService(repository: source).buildBackupJson();
final session = (json['sessions'] as List).single as Map<String, dynamic>;
final analysis = (session['analyses'] as List).single as Map<String, dynamic>;
expect(analysis.containsKey('image_base64'), isFalse);
});
test('l\'entretien d\'une arme supprimée n\'est pas perdu', () async {
final source = _FakeRepository(maintenance: [_maintenance()]);
final json = await BackupService(repository: source).buildBackupJson();
expect(json['orphan_maintenance'], hasLength(1));
});
});
group('import', () {
test('aller-retour complet : tout est restauré à l\'identique', () async {
final source = _FakeRepository(
sessions: [_session()],
weapons: [_weapon()],
maintenance: [_maintenance()],
);
final json = await BackupService(repository: source).buildBackupJson();
final target = _FakeRepository();
final service = BackupService(repository: target);
final preview = await _roundTrip(json, service);
expect(preview.sessionCount, 1);
expect(preview.shotCount, 2);
expect(preview.weaponCount, 1);
expect(preview.maintenanceCount, 1);
expect(preview.hasImages, isFalse);
final result = await service.applyBackup(preview);
expect(result.errors, isEmpty);
expect(result.sessions, 1);
expect(result.weapons, 1);
expect(result.maintenance, 1);
final session = target.sessions.single;
expect(session.id, 's1');
expect(session.weapon, 'Glock 17');
expect(session.distance, 25);
expect(session.createdAt, DateTime(2026, 3, 14, 10, 30));
expect(session.totalShots, 2);
expect(session.totalScore, 18);
expect(session.analyses.single.targetRadius, 0.4);
expect(session.analyses.single.shots.first.score, 10);
expect(target.weapons.single.customName, 'La bleue');
expect(target.weapons.single.magazineCapacity, 17);
expect(target.maintenance.single.roundsSinceLastMaintenance, 500);
});
test('réimporter deux fois ne crée pas de doublon', () async {
final source = _FakeRepository(
sessions: [_session()],
weapons: [_weapon()],
);
final json = await BackupService(repository: source).buildBackupJson();
final target = _FakeRepository();
final service = BackupService(repository: target);
await service.applyBackup(await _roundTrip(json, service));
await service.applyBackup(await _roundTrip(json, service));
expect(target.sessions, hasLength(1));
expect(target.weapons, hasLength(1));
});
test('les photos embarquées sont réécrites sur le disque', () async {
final bytes = utf8.encode('fausse-image');
final json = {
'format': BackupService.formatId,
'version': 1,
'sessions': [
{
'id': 's1',
'weapon': 'Glock 17',
'max_shots_per_target': 5,
'created_at': '2026-03-14T10:30:00.000',
'distance': 25,
'analyses': [
{
'id': 'a1',
'session_id': 's1',
'target_type': 'concentric',
'image_path': '/ancien/chemin.jpg',
'total_score': 10,
'created_at': '2026-03-14T10:35:00.000',
'image_extension': '.jpg',
'image_base64': base64Encode(bytes),
'shots': [
{'id': 't1', 'x': 0.5, 'y': 0.5, 'score': 10, 'analysis_id': 'a1'},
],
},
],
},
],
};
final target = _FakeRepository();
final service = BackupService(repository: target);
final preview = await _roundTrip(json, service);
expect(preview.hasImages, isTrue);
final result = await service.applyBackup(preview);
expect(result.images, 1);
expect(target.savedImages.single, bytes);
expect(target.sessions.single.analyses.single.imagePath,
'/imported/image_1.jpg');
});
test('un nombre entier là où un décimal est attendu ne casse rien', () async {
// JSON ne distingue pas 1 de 1.0 : un fichier édité à la main peut
// livrer des entiers là où les modèles attendent des doubles.
final json = {
'format': BackupService.formatId,
'version': 1,
'sessions': [
{
'id': 's1',
'weapon': 'Glock 17',
'max_shots_per_target': 5,
'created_at': '2026-03-14T10:30:00.000',
'analyses': [
{
'id': 'a1',
'session_id': 's1',
'target_type': 'concentric',
'image_path': '/photo.jpg',
'total_score': 10,
'created_at': '2026-03-14T10:35:00.000',
'target_radius': 1,
'grouping_diameter': 0,
'shots': const [],
},
],
},
],
};
final target = _FakeRepository();
final service = BackupService(repository: target);
final result = await service.applyBackup(await _roundTrip(json, service));
expect(result.errors, isEmpty);
expect(target.sessions.single.analyses.single.targetRadius, 1.0);
expect(target.sessions.single.analyses.single.groupingDiameter, 0.0);
});
test('un fichier étranger est refusé avec un message clair', () async {
final service = BackupService(repository: _FakeRepository());
final dir = await Directory.systemTemp.createTemp('impact_backup_test');
addTearDown(() => dir.delete(recursive: true));
final notJson = File('${dir.path}/photo.jpg');
await notJson.writeAsString('pas du json du tout');
expect(
() => service.readBackup(notJson),
throwsA(isA<BackupFormatException>()),
);
final otherJson = File('${dir.path}/autre.json');
await otherJson.writeAsString('{"hello": "world"}');
expect(
() => service.readBackup(otherJson),
throwsA(isA<BackupFormatException>()),
);
});
test('une sauvegarde plus récente que l\'app est refusée', () async {
final service = BackupService(repository: _FakeRepository());
final dir = await Directory.systemTemp.createTemp('impact_backup_test');
addTearDown(() => dir.delete(recursive: true));
final file = File('${dir.path}/futur.json');
await file.writeAsString(jsonEncode({
'format': BackupService.formatId,
'version': BackupService.formatVersion + 1,
}));
expect(
() => service.readBackup(file),
throwsA(isA<BackupFormatException>()),
);
});
test('une session corrompue est ignorée sans bloquer les autres', () async {
final valid = await BackupService(
repository: _FakeRepository(sessions: [_session()]),
).buildBackupJson();
// On injecte une session sans date : elle doit être la seule écartée.
(valid['sessions'] as List).add({
'id': 'corrompue',
'weapon': 'X',
'max_shots_per_target': 5,
'analyses': const [],
});
final target = _FakeRepository();
final service = BackupService(repository: target);
final result = await service.applyBackup(await _roundTrip(valid, service));
expect(result.sessions, 1);
expect(result.errors, hasLength(1));
expect(target.sessions.single.id, 's1');
});
});
}
@@ -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');
});
});
}
@@ -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);
});
});
}
+23 -11
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 // Le montage complet de BullyApp exige les providers globaux et une base
// utility in the flutter_test package. For example, you can send tap and scroll // SQLite initialisée ; pour un test widget rapide et stable, on valide ici
// gestures. You can also use WidgetTester to find child widgets in the widget // le rendu d'un composant autonome.
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:bully/app.dart'; import 'package:bully/features/home/widgets/stats_card.dart';
void main() { void main() {
testWidgets('App loads correctly', (WidgetTester tester) async { testWidgets('StatsCard affiche le titre, la valeur et l\'icône',
// Build our app and trigger a frame. (WidgetTester tester) async {
await tester.pumpWidget(const BullyApp()); await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: StatsCard(
icon: Icons.gps_fixed,
title: 'Tirs',
value: '42',
color: Colors.blue,
),
),
),
);
// Verify that the app title is displayed. expect(find.text('Tirs'), findsOneWidget);
expect(find.text('Bully'), findsOneWidget); expect(find.text('42'), findsOneWidget);
expect(find.byIcon(Icons.gps_fixed), findsOneWidget);
}); });
} }
-12
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);
}
-7
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);
}
-5
View File
@@ -1,5 +0,0 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.findHomography);
}
@@ -8,10 +8,16 @@
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar( PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }
+2
View File
@@ -5,6 +5,8 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows file_selector_windows
permission_handler_windows permission_handler_windows
share_plus
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST