Compare commits

..
Author SHA1 Message Date
streaper2 1b2310b12b ci: ajout du deploiement continu avec gitea actions
Deploy Backendia / deploy (push) Failing after 42s
2026-08-25 22:28:33 +02:00
streaper2 44ac4462a6 feat(ai-training): ajout validation OpenCV, gestion des logs et modération des contributions
- Backend IA :
  - Intégration d'OpenCV (WebAssembly) pour la détection et certification des cibles (anneaux concentriques, confiance).
  - Ajout du système de journalisation complet des uploads (SQLite, IP, appareil, statut, métadonnées).
  - Implémentation du système de modération (bannissement/débannissement de wallets, rejet 403 avec motifs).
  - Harmonisation des codes d'erreur et messages JSON (UPLOAD_SUCCESS, WALLET_BANNED, etc.).
  - Dashboard Next.js : page dédiée aux logs avec filtres, export CSV et actions de modération en un clic.
- Application Mobile (Flutter) :
  - Encapsulation des réponses d'export dans AiExportResult avec gestion fine des erreurs et statuts.
  - Mise à jour du disclaimer de participation à l'entraînement IA avec avertissements stricts de bannissement.
  - Révocation de l'envoi de photos et affichage d'un bandeau explicatif en cas de suspension.
  - Ajout d'un bouton manuel « Actualiser mon statut » dans les paramètres pour synchroniser l'état du compte.
  - Possibilité de configurer l'URL/IP du serveur IA directement depuis les paramètres.
2026-08-25 12:16:29 +02:00
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
104 changed files with 7882 additions and 6328 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:*)"
]
}
}
+21
View File
@@ -0,0 +1,21 @@
name: Deploy Backendia
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 📥 Récupération du code
uses: actions/checkout@v4
- name: 🚀 Build et Déploiement Docker
run: |
echo "🚀 Démarrage du déploiement..."
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
echo "🧹 Nettoyage des anciennes images inutilisées..."
docker image prune -f
echo "✅ Déploiement terminé avec succès !"
+1
View File
@@ -11,6 +11,7 @@
.svn/
.swiftpm/
migrate_working_dir/
.claude/
# IntelliJ related
*.iml
+25 -10
View File
@@ -41,10 +41,12 @@ flutter test --coverage
## Features
### Analyse de cibles de tir
### Capture de cibles de tir
- Support de cibles concentriques (anneaux) et silhouettes
- Chargement d'images de cibles depuis la galerie ou la caméra
- Détection automatique du centre et du rayon de la cible
- Aperçu caméra avec aide au cadrage : détection OpenCV de la cible (mire)
et indicateur de parallélisme par accéléromètre (pitch/roll)
- Écran de centrage/recadrage : rotation fine, déplacement pixel par pixel
### Calibration des cibles
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
@@ -52,14 +54,13 @@ flutter test --coverage
- Slider global pour redimensionner tous les anneaux proportionnellement
- Visualisation en temps réel des zones de score
### Détection d'impacts
- **Ajout manuel** : cliquer sur l'image pour placer un impact
- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables
- Seuil de luminosité
- Taille min/max des impacts
- Circularité minimale
- Ratio de remplissage (distingue les trous pleins des cercles vides)
- **Détection par références** : sélectionner 2-4 impacts manuellement, l'algorithme apprend leurs caractéristiques et détecte les impacts similaires
### Placement des impacts
- **Éditeur d'impacts plein écran** : tap pour ajouter (y compris juste à côté
ou par-dessus un impact existant), appui long pour déplacer, pincer pour zoomer
- Un tap n'ouvre jamais d'édition de score : le score reste calculé
automatiquement d'après la position de l'impact
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de synthèse
efface tous les impacts sans toucher à la calibration
### Calcul des scores
- 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
- 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
- Sauvegarde des sessions avec date, score, notes
- 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
statistics_screen.dart Écran statistiques avec filtrage par période
heat_map_widget.dart Heat map avec gradient bleu→rouge
backup_service.dart Export/import JSON des sessions, stats et armurerie
+9
View File
@@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml
linter:
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
-3
View File
@@ -37,9 +37,6 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="docscanner" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log*
dashboard/node_modules
dashboard/.next
uploads
exports
.git
.env*
+1 -3
View File
@@ -13,8 +13,7 @@ dashboard/out/
.env.test.local
.env.production.local
# Database
backendia/uploads/data/database.sqlite
# Database (contient des données utilisateurs — jamais dans git)
*.sqlite
*.sqlite-journal
@@ -23,7 +22,6 @@ uploads/images/*
!uploads/images/.gitkeep
uploads/data/*
!uploads/data/.gitkeep
!uploads/data/database.sqlite
exports/*
!exports/.gitkeep
+25
View File
@@ -0,0 +1,25 @@
FROM node:20-bookworm-slim
WORKDIR /app
# Dépendances système pour les modules natifs (sharp, sqlite3, opencv)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# Dossiers nécessaires
RUN mkdir -p uploads/images uploads/data exports
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
CMD ["node", "server.js"]
+8 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import { LayoutDashboard, Users, Image as ImageIcon } from "lucide-react";
import { LayoutDashboard, Users, ScrollText, Image as ImageIcon } from "lucide-react";
const inter = Inter({ subsets: ["latin"] });
@@ -41,6 +41,13 @@ export default function RootLayout({
<Users size={20} />
Contributeurs
</Link>
<Link
href="/logs"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<ScrollText size={20} />
Logs d'Uploads
</Link>
</nav>
<div className="mt-auto pt-6 border-t border-slate-800">
@@ -0,0 +1,900 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import {
ScrollText,
Search,
Download,
RefreshCw,
CheckCircle2,
XCircle,
Copy,
Check,
Trash2,
Smartphone,
Crosshair,
Calendar,
HardDrive,
Users,
ExternalLink,
Info,
Filter,
Eye,
ShieldAlert,
ShieldCheck,
Ban,
AlertTriangle
} from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import Link from "next/link";
interface UploadLog {
id: number;
timestamp: string;
session_id: string | null;
wallet_hash: string | null;
image_filename: string | null;
json_filename: string | null;
file_size: number | null;
ip_address: string | null;
user_agent: string | null;
device_model: string | null;
device_os: string | null;
target_type: string | null;
weapon: string | null;
distance_meters: number | null;
impacts_count: number;
status: string;
target_valid?: number;
target_status?: string;
target_confidence?: number;
target_rings_count?: number;
target_details?: string | null;
error_message: string | null;
raw_metadata: string | null;
}
interface BannedWallet {
wallet_hash: string;
reason: string;
banned_at: string;
banned_by: string;
}
interface LogStats {
total_uploads: number;
success_count: number;
failed_count: number;
today_uploads: number;
unique_wallets: number;
total_bytes: number;
latest_upload: string | null;
}
export default function LogsManager({
initialLogs,
initialStats
}: {
initialLogs: UploadLog[];
initialStats: LogStats | null;
}) {
const [logs, setLogs] = useState<UploadLog[]>(initialLogs);
const [stats, setStats] = useState<LogStats | null>(initialStats);
const [bannedWallets, setBannedWallets] = useState<BannedWallet[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | "SUCCESS" | "FAILED" | "INVALID_TARGET" | "BANNED">("ALL");
const [copiedWallet, setCopiedWallet] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<UploadLog | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
// Moderation state
const [banModalWallet, setBanModalWallet] = useState<string | null>(null);
const [banReason, setBanReason] = useState("Images non conformes / Fausse cible");
const [isSubmittingBan, setIsSubmittingBan] = useState(false);
const fetchBannedWallets = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/moderation/banned`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
setBannedWallets(data.banned || []);
}
} catch (e) {
console.error("Erreur récupération wallets bannis:", e);
}
};
useEffect(() => {
fetchBannedWallets();
}, []);
const fetchLogs = async () => {
setIsLoading(true);
try {
const [logsRes, statsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/logs?limit=200`, { cache: 'no-store' }),
fetch(`${API_BASE_URL}/api/logs/stats`, { cache: 'no-store' }),
fetchBannedWallets()
]);
if (logsRes.ok) {
const logsData = await logsRes.json();
setLogs(logsData.logs || []);
}
if (statsRes.ok) {
const statsData = await statsRes.json();
setStats(statsData.stats || null);
}
} catch (e) {
console.error("Erreur lors de la récupération des logs:", e);
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedWallet(text);
setTimeout(() => setCopiedWallet(null), 2000);
};
const isWalletBanned = (walletHash: string | null) => {
if (!walletHash) return false;
return bannedWallets.some(b => b.wallet_hash === walletHash);
};
const getBannedInfo = (walletHash: string | null) => {
if (!walletHash) return null;
return bannedWallets.find(b => b.wallet_hash === walletHash) || null;
};
const handleBanWallet = async () => {
if (!banModalWallet) return;
setIsSubmittingBan(true);
try {
const res = await fetch(`${API_BASE_URL}/api/moderation/ban`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wallet_hash: banModalWallet,
reason: banReason,
banned_by: 'Admin Dashboard'
})
});
if (res.ok) {
setBanModalWallet(null);
await fetchBannedWallets();
await fetchLogs();
} else {
alert("Erreur lors du bannissement.");
}
} catch (e) {
console.error("Erreur ban:", e);
} finally {
setIsSubmittingBan(false);
}
};
const handleUnbanWallet = async (walletHash: string) => {
if (!confirm(`Voulez-vous vraiment débannir le wallet ${walletHash.substring(0, 10)}... ?`)) return;
try {
const res = await fetch(`${API_BASE_URL}/api/moderation/ban/${walletHash}`, {
method: 'DELETE'
});
if (res.ok) {
await fetchBannedWallets();
await fetchLogs();
}
} catch (e) {
console.error("Erreur unban:", e);
}
};
const handleDeleteLog = async (id: number) => {
if (!confirm("Voulez-vous vraiment supprimer ce log ?")) return;
try {
const res = await fetch(`${API_BASE_URL}/api/logs/${id}`, { method: 'DELETE' });
if (res.ok) {
setLogs(prev => prev.filter(l => l.id !== id));
fetchLogs();
}
} catch (e) {
console.error("Erreur suppression:", e);
}
};
const handleClearAll = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/logs`, { method: 'DELETE' });
if (res.ok) {
setLogs([]);
setShowClearConfirm(false);
fetchLogs();
}
} catch (e) {
console.error("Erreur vidage logs:", e);
}
};
const formatFileSize = (bytes: number | null) => {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const filteredLogs = useMemo(() => {
return logs.filter(log => {
// Filter status
if (statusFilter === "SUCCESS" && log.status !== "SUCCESS") return false;
if (statusFilter === "FAILED" && log.status !== "FAILED") return false;
if (statusFilter === "INVALID_TARGET" && log.target_status === "VALID") return false;
if (statusFilter === "BANNED" && !isWalletBanned(log.wallet_hash)) return false;
// Filter search
if (!searchTerm.trim()) return true;
const term = searchTerm.toLowerCase();
return (
(log.wallet_hash && log.wallet_hash.toLowerCase().includes(term)) ||
(log.session_id && log.session_id.toLowerCase().includes(term)) ||
(log.image_filename && log.image_filename.toLowerCase().includes(term)) ||
(log.device_model && log.device_model.toLowerCase().includes(term)) ||
(log.weapon && log.weapon.toLowerCase().includes(term)) ||
(log.target_type && log.target_type.toLowerCase().includes(term)) ||
(log.target_status && log.target_status.toLowerCase().includes(term)) ||
(log.ip_address && log.ip_address.toLowerCase().includes(term))
);
});
}, [logs, searchTerm, statusFilter, bannedWallets]);
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
<ScrollText size={24} />
</div>
<div>
<h2 className="text-3xl font-bold tracking-tight">Journal & Contrôle des Uploads</h2>
<p className="text-slate-400 text-sm">
Historique des transferts, diagnostic OpenCV (détection cibles) et modération des wallets.
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={fetchLogs}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300 hover:text-white rounded-xl text-sm font-semibold transition-all disabled:opacity-50"
title="Rafraîchir les logs"
>
<RefreshCw size={16} className={isLoading ? "animate-spin text-indigo-400" : ""} />
<span>Actualiser</span>
</button>
<a
href={`${API_BASE_URL}/api/logs/export?format=csv`}
download
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-semibold transition-all shadow-lg shadow-indigo-600/20 hover:scale-105 active:scale-95"
>
<Download size={16} />
<span>Exporter CSV</span>
</a>
{!showClearConfirm ? (
<button
onClick={() => setShowClearConfirm(true)}
className="flex items-center gap-2 px-3 py-2.5 bg-slate-900 border border-slate-800 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 rounded-xl text-sm transition-all"
title="Purger tous les logs"
>
<Trash2 size={16} />
</button>
) : (
<div className="flex items-center gap-2 p-1 bg-rose-500/10 border border-rose-500/30 rounded-xl animate-in fade-in">
<span className="text-xs text-rose-400 px-2 font-semibold">Effacer tout ?</span>
<button
onClick={handleClearAll}
className="px-2.5 py-1 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold transition-all"
>
Oui
</button>
<button
onClick={() => setShowClearConfirm(false)}
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs transition-all"
>
Non
</button>
</div>
)}
</div>
</div>
{/* KPI Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Total Uploads</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold">{stats?.total_uploads ?? logs.length}</p>
{stats && stats.total_uploads > 0 && (
<span className="text-xs font-semibold text-emerald-400">
{Math.round(((stats.success_count || 0) / stats.total_uploads) * 100)}% succès
</span>
)}
</div>
</div>
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
<ScrollText size={20} />
</div>
</div>
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Aujourd'hui</p>
<p className="text-2xl font-bold text-indigo-400">{stats?.today_uploads ?? 0}</p>
</div>
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
<Calendar size={20} />
</div>
</div>
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallets Bannis</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold text-rose-400">{bannedWallets.length}</p>
<span className="text-xs text-slate-500 font-mono">/ {stats?.unique_wallets ?? 0} total</span>
</div>
</div>
<div className="w-11 h-11 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-xl flex items-center justify-center">
<Ban size={20} />
</div>
</div>
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Volume Transféré</p>
<p className="text-2xl font-bold text-emerald-400">
{formatFileSize(stats?.total_bytes || logs.reduce((acc, l) => acc + (l.file_size || 0), 0))}
</p>
</div>
<div className="w-11 h-11 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-xl flex items-center justify-center">
<HardDrive size={20} />
</div>
</div>
</div>
{/* Filters & Search Toolbar */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex flex-col md:flex-row gap-4 items-center justify-between">
{/* Search */}
<div className="relative w-full md:w-96">
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Rechercher par Wallet, Session, Cible, Image..."
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-xl pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 outline-none transition-all"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-300"
>
Effacer
</button>
)}
</div>
{/* Status & OpenCV Filter */}
<div className="flex items-center gap-2 w-full md:w-auto justify-start md:justify-end flex-wrap">
<span className="text-xs text-slate-500 font-semibold uppercase tracking-wider flex items-center gap-1.5 mr-1">
<Filter size={13} />
Filtre:
</span>
<div className="inline-flex bg-slate-950 p-1 rounded-xl border border-slate-800 flex-wrap gap-1">
<button
onClick={() => setStatusFilter("ALL")}
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
statusFilter === "ALL"
? "bg-slate-800 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
Tous ({logs.length})
</button>
<button
onClick={() => setStatusFilter("SUCCESS")}
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
statusFilter === "SUCCESS"
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"
: "text-slate-400 hover:text-emerald-400"
}`}
>
Succès
</button>
<button
onClick={() => setStatusFilter("INVALID_TARGET")}
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
statusFilter === "INVALID_TARGET"
? "bg-amber-500/20 text-amber-300 border border-amber-500/30"
: "text-slate-400 hover:text-amber-400"
}`}
title="Photos non reconnues comme cible par OpenCV"
>
Cibles Douteuses
</button>
<button
onClick={() => setStatusFilter("BANNED")}
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
statusFilter === "BANNED"
? "bg-rose-500/20 text-rose-300 border border-rose-500/30"
: "text-slate-400 hover:text-rose-400"
}`}
>
Bannis ({bannedWallets.length})
</button>
</div>
</div>
</div>
{/* Logs Table */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl shadow-black/20">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-slate-950/60 border-b border-slate-800 text-slate-400 font-semibold text-xs uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">Date & Heure</th>
<th className="px-4 py-3.5">Diagnostic OpenCV</th>
<th className="px-4 py-3.5">Wallet Contributeur</th>
<th className="px-4 py-3.5">Session / Cible</th>
<th className="px-4 py-3.5">Appareil</th>
<th className="px-4 py-3.5">Fichier</th>
<th className="px-4 py-3.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{filteredLogs.map((log) => {
const dateObj = new Date(log.timestamp);
const isValidDate = !isNaN(dateObj.getTime());
const formattedDate = isValidDate
? dateObj.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })
: log.timestamp;
const formattedTime = isValidDate
? dateObj.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
: "";
const banned = isWalletBanned(log.wallet_hash);
return (
<tr
key={log.id}
className="hover:bg-slate-800/40 transition-colors group"
>
{/* Timestamp */}
<td className="px-5 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="font-semibold text-slate-200">{formattedDate}</span>
<span className="text-xs text-slate-500 font-mono">{formattedTime}</span>
</div>
</td>
{/* OpenCV Diagnosis & Upload Status */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex flex-col gap-1">
{log.status === "FAILED" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.error_message || "Échec upload"}>
<XCircle size={12} />
UPLOAD ÉCHEC
</span>
) : log.target_status === "VALID" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 max-w-fit" title={log.target_details || "Cible certifiée"}>
<ShieldCheck size={12} />
CIBLE CERTIFIÉE ({Math.round((log.target_confidence || 1) * 100)}%)
</span>
) : log.target_status === "SUSPICIOUS" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20 max-w-fit" title={log.target_details || "1 seul anneau détecté"}>
<AlertTriangle size={12} />
DOUTEUSE (1 ANNEAU)
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.target_details || "Aucun motif de cible"}>
<ShieldAlert size={12} />
NON RECONNUE
</span>
)}
{log.target_rings_count ? (
<span className="text-[10px] text-slate-500 font-mono">
{log.target_rings_count} anneaux concentriques
</span>
) : null}
</div>
</td>
{/* Wallet Hash & Ban Badge */}
<td className="px-4 py-4">
{log.wallet_hash ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span
onClick={() => setSearchTerm(log.wallet_hash || "")}
className="font-mono text-xs text-indigo-300 hover:text-indigo-200 bg-indigo-500/5 px-2 py-1 rounded border border-indigo-500/10 hover:border-indigo-500/30 cursor-pointer transition-colors"
title="Cliquer pour filtrer par ce wallet"
>
{log.wallet_hash.length > 16
? `${log.wallet_hash.substring(0, 8)}...${log.wallet_hash.substring(log.wallet_hash.length - 8)}`
: log.wallet_hash}
</span>
<button
onClick={() => copyToClipboard(log.wallet_hash!)}
className="text-slate-500 hover:text-slate-300 transition-colors p-1"
title="Copier le hash du wallet"
>
{copiedWallet === log.wallet_hash ? (
<Check size={14} className="text-emerald-400" />
) : (
<Copy size={14} />
)}
</button>
</div>
{banned && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20 max-w-fit">
<Ban size={10} />
WALLET BANNI
</span>
)}
</div>
) : (
<span className="text-xs text-slate-500 italic">Anonyme / Non fourni</span>
)}
</td>
{/* Session & Target */}
<td className="px-4 py-4">
<div className="flex flex-col gap-1 max-w-[180px]">
{log.session_id && (
<div className="flex items-center gap-1 text-xs text-slate-300 font-mono truncate">
<span className="text-slate-500">ID:</span> {log.session_id}
</div>
)}
<div className="flex items-center flex-wrap gap-1.5">
{log.target_type && (
<span className="text-[10px] uppercase font-bold bg-slate-800 text-slate-300 px-1.5 py-0.5 rounded border border-slate-700">
{log.target_type}
</span>
)}
{log.weapon && (
<span className="text-[10px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded">
{log.weapon}
</span>
)}
{log.impacts_count > 0 && (
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 flex items-center gap-0.5">
<Crosshair size={10} />
{log.impacts_count} imp.
</span>
)}
</div>
</div>
</td>
{/* Device & IP */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Smartphone size={13} className="text-indigo-400" />
<span>{log.device_model || log.device_os || "Inconnu"}</span>
</div>
{log.ip_address && (
<span className="text-[11px] text-slate-500 font-mono">
{log.ip_address.replace("::ffff:", "")}
</span>
)}
</div>
</td>
{/* File & Size */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
{log.image_filename ? (
<Link
href={`/photo/${log.image_filename}`}
className="text-xs text-indigo-400 hover:text-indigo-300 font-mono flex items-center gap-1 group/link truncate max-w-[130px]"
title={log.image_filename}
>
<span className="truncate">{log.image_filename}</span>
<ExternalLink size={12} className="opacity-0 group-hover/link:opacity-100 transition-opacity" />
</Link>
) : (
<span className="text-xs text-slate-500 italic">Aucun fichier</span>
)}
<span className="text-[11px] text-slate-500 font-mono">
{formatFileSize(log.file_size)}
</span>
</div>
</td>
{/* Actions */}
<td className="px-4 py-4 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-1.5">
{/* Ban / Unban Button */}
{log.wallet_hash && (
banned ? (
<button
onClick={() => handleUnbanWallet(log.wallet_hash!)}
className="p-1.5 text-slate-400 hover:text-emerald-400 hover:bg-slate-800 rounded-lg transition-colors"
title="Débannir ce wallet"
>
<ShieldCheck size={16} />
</button>
) : (
<button
onClick={() => setBanModalWallet(log.wallet_hash)}
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
title="Bannir définitivement ce wallet"
>
<Ban size={16} />
</button>
)
)}
<button
onClick={() => setSelectedLog(log)}
className="p-1.5 text-slate-400 hover:text-indigo-300 hover:bg-slate-800 rounded-lg transition-colors"
title="Voir les détails complets"
>
<Eye size={16} />
</button>
<button
onClick={() => handleDeleteLog(log.id)}
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
title="Supprimer ce log"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Empty state */}
{filteredLogs.length === 0 && (
<div className="py-20 flex flex-col items-center justify-center text-center px-4">
<div className="p-4 bg-slate-950 border border-slate-800 rounded-2xl text-slate-600 mb-3">
<ScrollText size={36} />
</div>
<h3 className="text-slate-300 font-bold text-base">Aucun log trouvé</h3>
<p className="text-slate-500 text-xs mt-1 max-w-sm">
{searchTerm || statusFilter !== "ALL"
? "Aucun enregistrement ne correspond à vos critères de recherche."
: "Les logs apparaîtront ici dès que des photos ou des sessions seront uploadées."}
</p>
</div>
)}
</div>
{/* Details Modal */}
{selectedLog && (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
{/* Modal Header */}
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
<Info size={20} />
</div>
<div>
<h3 className="font-bold text-lg text-slate-100">Détails du Log #{selectedLog.id}</h3>
<p className="text-xs text-slate-400">{new Date(selectedLog.timestamp).toLocaleString()}</p>
</div>
</div>
<button
onClick={() => setSelectedLog(null)}
className="text-slate-400 hover:text-white p-2 hover:bg-slate-800 rounded-xl transition-colors"
>
</button>
</div>
{/* Modal Content */}
<div className="p-6 overflow-y-auto space-y-6 text-sm">
{/* OpenCV Diagnostic Banner */}
<div className={`p-4 rounded-xl border flex flex-col gap-2 ${
selectedLog.target_status === "VALID"
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-300"
: selectedLog.target_status === "SUSPICIOUS"
? "bg-amber-500/10 border-amber-500/20 text-amber-300"
: "bg-rose-500/10 border-rose-500/20 text-rose-300"
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 font-bold">
{selectedLog.target_status === "VALID" ? <ShieldCheck size={18} /> : <AlertTriangle size={18} />}
Diagnostic OpenCV : {selectedLog.target_status || "VALID"}
</div>
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded bg-black/30">
Confiance : {Math.round((selectedLog.target_confidence || 1) * 100)}%
</span>
</div>
<p className="text-xs opacity-90">{selectedLog.target_details || "Cible détectée avec succès."}</p>
</div>
{/* General Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallet Hash</span>
<div className="font-mono text-xs text-indigo-300 break-all select-all">
{selectedLog.wallet_hash || "Non spécifié"}
</div>
{isWalletBanned(selectedLog.wallet_hash) && (
<span className="inline-block mt-1 text-[10px] text-rose-400 font-bold bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20">
Banni ({getBannedInfo(selectedLog.wallet_hash)?.reason})
</span>
)}
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Session ID</span>
<div className="font-mono text-xs text-slate-300 break-all select-all">
{selectedLog.session_id || "N/A"}
</div>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Appareil</span>
<div className="text-xs text-slate-300">
{selectedLog.device_model || "Inconnu"} ({selectedLog.device_os || "OS inconnu"})
</div>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Adresse IP</span>
<div className="font-mono text-xs text-slate-300">
{selectedLog.ip_address || "Inconnue"}
</div>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Cible & Arme</span>
<div className="text-xs text-slate-300">
Type: <span className="font-bold text-white">{selectedLog.target_type || "N/A"}</span> | Arme: {selectedLog.weapon || "N/A"} | {selectedLog.distance_meters ? `${selectedLog.distance_meters}m` : ""}
</div>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Fichiers & Taille</span>
<div className="text-xs text-slate-300">
Image: <span className="font-mono">{selectedLog.image_filename || "N/A"}</span> ({formatFileSize(selectedLog.file_size)})
</div>
</div>
</div>
{/* Raw Metadata JSON */}
{selectedLog.raw_metadata && (
<div className="space-y-2">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Données Brutes (JSON)</span>
<pre className="bg-slate-950 border border-slate-800 p-4 rounded-xl text-xs font-mono text-slate-300 overflow-x-auto max-h-48">
{(() => {
try {
return JSON.stringify(JSON.parse(selectedLog.raw_metadata), null, 2);
} catch {
return selectedLog.raw_metadata;
}
})()}
</pre>
</div>
)}
</div>
{/* Modal Footer */}
<div className="p-4 border-t border-slate-800 bg-slate-950/50 flex justify-between items-center">
<div className="flex items-center gap-2">
{selectedLog.image_filename && (
<Link
href={`/photo/${selectedLog.image_filename}`}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold transition-all"
>
<Eye size={14} />
Ouvrir la session
</Link>
)}
{selectedLog.wallet_hash && (
isWalletBanned(selectedLog.wallet_hash) ? (
<button
onClick={() => handleUnbanWallet(selectedLog.wallet_hash!)}
className="px-3 py-2 bg-slate-800 hover:bg-emerald-600/30 text-emerald-400 rounded-xl text-xs font-bold transition-all border border-emerald-500/20"
>
Débannir ce wallet
</button>
) : (
<button
onClick={() => {
setSelectedLog(null);
setBanModalWallet(selectedLog.wallet_hash);
}}
className="px-3 py-2 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20"
>
Bannir ce wallet
</button>
)
)}
</div>
<button
onClick={() => setSelectedLog(null)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
>
Fermer
</button>
</div>
</div>
</div>
)}
{/* Ban Confirmation Modal */}
{banModalWallet && (
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 space-y-6 shadow-2xl animate-in zoom-in-95 duration-200">
<div className="flex items-center gap-3">
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400">
<Ban size={24} />
</div>
<div>
<h3 className="font-bold text-lg text-slate-100">Bannir un Wallet</h3>
<p className="text-xs text-slate-400">Bloquer définitivement tout upload futur de ce compte.</p>
</div>
</div>
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 font-mono text-xs text-indigo-300 break-all">
{banModalWallet}
</div>
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
Motif du bannissement
</label>
<select
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 focus:border-rose-500 rounded-xl px-3 py-2 text-sm text-slate-200 outline-none"
>
<option value="Images non conformes / Fausse cible">Images non conformes / Fausse cible</option>
<option value="Spam / Uploads abusifs répétés">Spam / Uploads abusifs répétés</option>
<option value="Contenu inapproprié ou illicite">Contenu inapproprié ou illicite</option>
<option value="Tentative de manipulation des données">Tentative de manipulation des données</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setBanModalWallet(null)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
>
Annuler
</button>
<button
onClick={handleBanWallet}
disabled={isSubmittingBan}
className="px-4 py-2 bg-rose-600 hover:bg-rose-500 text-white rounded-xl text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-1.5"
>
<Ban size={14} />
{isSubmittingBan ? "Bannissement..." : "Confirmer le Bannissement"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { fetchApi } from "@/lib/api";
import LogsManager from "./LogsManager";
export const metadata = {
title: "Logs d'Uploads | Bully IA Dashboard",
description: "Journal complet des téléversements de sessions et métadonnées",
};
export default async function LogsPage() {
let logs = [];
let stats = null;
try {
const [logsRes, statsRes] = await Promise.all([
fetchApi('/api/logs?limit=100'),
fetchApi('/api/logs/stats')
]);
logs = logsRes.logs || [];
stats = statsRes.stats || null;
} catch (error) {
console.error("Erreur chargement initial des logs:", error);
}
return (
<LogsManager
initialLogs={logs}
initialStats={stats}
/>
);
}
@@ -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.
+577 -3
View File
@@ -9,14 +9,526 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@techstark/opencv-js": "^5.0.0-release.1",
"adm-zip": "^0.5.17",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"multer": "^2.1.1",
"sharp": "^0.35.3",
"sqlite3": "^6.0.1"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -29,6 +541,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@techstark/opencv-js": {
"version": "5.0.0-release.1",
"resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz",
"integrity": "sha512-PIm+eB0MFtieXoNC2GRao0dv/02sehG+Nv2nSW5D6pQm6J/4WqvDHm0RyoqOmGYQm67jdGiaOdIeTShCY3PIUg==",
"license": "Apache-2.0"
},
"node_modules/abbrev": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
@@ -1264,9 +1782,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1326,6 +1844,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1581,6 +2148,13 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+2
View File
@@ -12,11 +12,13 @@
"author": "",
"license": "ISC",
"dependencies": {
"@techstark/opencv-js": "^5.0.0-release.1",
"adm-zip": "^0.5.17",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"multer": "^2.1.1",
"sharp": "^0.35.3",
"sqlite3": "^6.0.1"
}
}
+493 -12
View File
@@ -7,6 +7,8 @@ const sqlite3 = require('sqlite3').verbose();
const AdmZip = require('adm-zip');
const TargetValidator = require('./services/target_validator');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -39,9 +41,119 @@ const db = new sqlite3.Database(dbPath, (err) => {
photo_count INTEGER DEFAULT 0,
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS banned_wallets (
wallet_hash TEXT PRIMARY KEY,
reason TEXT,
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
banned_by TEXT DEFAULT 'Admin'
)`);
db.run(`CREATE TABLE IF NOT EXISTS upload_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
session_id TEXT,
wallet_hash TEXT,
image_filename TEXT,
json_filename TEXT,
file_size INTEGER,
ip_address TEXT,
user_agent TEXT,
device_model TEXT,
device_os TEXT,
target_type TEXT,
weapon TEXT,
distance_meters INTEGER,
impacts_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'SUCCESS',
target_valid INTEGER DEFAULT 1,
target_status TEXT DEFAULT 'VALID',
target_confidence REAL DEFAULT 1.0,
target_rings_count INTEGER DEFAULT 0,
target_details TEXT,
error_message TEXT,
raw_metadata TEXT
)`);
// Migration safe des colonnes OpenCV si la table existait déjà
const migrations = [
"ALTER TABLE upload_logs ADD COLUMN target_valid INTEGER DEFAULT 1",
"ALTER TABLE upload_logs ADD COLUMN target_status TEXT DEFAULT 'VALID'",
"ALTER TABLE upload_logs ADD COLUMN target_confidence REAL DEFAULT 1.0",
"ALTER TABLE upload_logs ADD COLUMN target_rings_count INTEGER DEFAULT 0",
"ALTER TABLE upload_logs ADD COLUMN target_details TEXT"
];
migrations.forEach(sql => db.run(sql, () => {}));
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_wallet ON upload_logs(wallet_hash)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_timestamp ON upload_logs(timestamp)`);
}
});
// Helper pour insérer un log d'upload
function logUploadEntry({
sessionId = null,
walletHash = null,
imageFilename = null,
jsonFilename = null,
fileSize = null,
ipAddress = null,
userAgent = null,
deviceModel = null,
deviceOs = null,
targetType = null,
weapon = null,
distanceMeters = null,
impactsCount = 0,
status = 'SUCCESS',
targetValid = 1,
targetStatus = 'VALID',
targetConfidence = 1.0,
targetRingsCount = 0,
targetDetails = null,
errorMessage = null,
rawMetadata = null
}) {
const query = `
INSERT INTO upload_logs (
session_id, wallet_hash, image_filename, json_filename, file_size,
ip_address, user_agent, device_model, device_os, target_type,
weapon, distance_meters, impacts_count, status,
target_valid, target_status, target_confidence, target_rings_count, target_details,
error_message, raw_metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(query, [
sessionId,
walletHash,
imageFilename,
jsonFilename,
fileSize,
ipAddress,
userAgent,
deviceModel,
deviceOs,
targetType,
weapon,
distanceMeters,
impactsCount,
status,
targetValid ? 1 : 0,
targetStatus,
targetConfidence,
targetRingsCount,
targetDetails,
errorMessage,
rawMetadata ? JSON.stringify(rawMetadata) : null
], function(err) {
if (err) {
console.error("Erreur lors de l'insertion dans upload_logs:", err.message);
} else {
console.log(`[LOG] Upload enregistré (ID: ${this.lastID}) - Statut: ${status} - OpenCV: ${targetStatus}`);
}
});
}
// Configuration de multer pour le stockage des fichiers
const storage = multer.diskStorage({
destination: function (req, file, cb) {
@@ -81,10 +193,22 @@ app.get('/api/health', (req, res) => {
// Route pour l'upload de photo + données JSON
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
app.post('/api/upload', upload.single('photo'), (req, res) => {
app.post('/api/upload', upload.single('photo'), async (req, res) => {
const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || '';
const userAgent = req.headers['user-agent'] || '';
try {
if (!req.file) {
return res.status(400).json({ error: 'Aucune photo fournie' });
logUploadEntry({
ipAddress,
userAgent,
status: 'FAILED',
errorMessage: 'Aucune photo fournie'
});
return res.status(400).json({
code: 'MISSING_PHOTO',
error: 'Aucune photo fournie dans la requête'
});
}
let plottingData = {};
@@ -93,10 +217,75 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
plottingData = JSON.parse(req.body.plotting);
} catch (e) {
console.error("Erreur parsing JSON:", e);
return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' });
logUploadEntry({
imageFilename: req.file.filename,
fileSize: req.file.size,
ipAddress,
userAgent,
status: 'FAILED',
errorMessage: 'Le champ plotting doit être un JSON valide'
});
return res.status(400).json({
code: 'INVALID_PLOTTING_JSON',
error: 'Le champ plotting contient un format JSON invalide'
});
}
}
const walletHash = plottingData.wallet_hash || null;
const sessionId = plottingData.session_id || null;
// 1. Vérification du bannissement de wallet
if (walletHash) {
const bannedEntry = await new Promise((resolve) => {
db.get('SELECT * FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, row) => {
resolve(row || null);
});
});
if (bannedEntry) {
// Supprimer le fichier image temporaire pour ne pas consommer d'espace
if (req.file.path && fs.existsSync(req.file.path)) {
try { fs.unlinkSync(req.file.path); } catch (e) {}
}
const banReason = bannedEntry.reason || 'Non-respect des règles de contribution / Image non conforme';
logUploadEntry({
sessionId,
walletHash,
imageFilename: req.file.filename,
fileSize: req.file.size,
ipAddress,
userAgent,
status: 'FAILED',
errorMessage: `Upload bloqué : wallet banni (${banReason})`
});
console.warn(`[MODÉRATION] Upload rejeté pour wallet banni: ${walletHash}`);
return res.status(403).json({
code: 'WALLET_BANNED',
error: 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
reason: banReason,
banned_at: bannedEntry.banned_at
});
}
}
// 2. Validation de la cible par OpenCV
let validation = {
isValid: true,
status: 'VALID',
confidence: 1.0,
ringsCount: 0,
bestCenter: null,
details: 'Analyse non effectuée'
};
try {
validation = await TargetValidator.validateTarget(req.file.path);
console.log(`[OPENCV] Diagnostic cible pour ${req.file.filename}: ${validation.status} (${Math.round(validation.confidence * 100)}% conf, ${validation.ringsCount} anneaux)`);
} catch (cvErr) {
console.error("[OPENCV] Erreur analyse cible:", cvErr);
}
// Nom de base sans l'extension
const baseFilename = path.parse(req.file.filename).name;
const jsonFilename = `${baseFilename}.json`;
@@ -105,8 +294,13 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
// Sauvegarde du JSON dans uploads/data/
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
// Extraction des métadonnées
const deviceInfo = plottingData.device_info || {};
const targetMeta = plottingData.target_metadata || {};
const impacts = plottingData.plotting?.impacts || [];
const impactsCount = Array.isArray(impacts) ? impacts.length : 0;
// Mise à jour de la BDD si on a un wallet_hash
const walletHash = plottingData.wallet_hash;
if (walletHash) {
db.run(`
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
@@ -123,11 +317,36 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
});
}
// Enregistrement dans la table des logs d'upload avec diagnostic OpenCV
logUploadEntry({
sessionId,
walletHash,
imageFilename: req.file.filename,
jsonFilename,
fileSize: req.file.size,
ipAddress,
userAgent,
deviceModel: deviceInfo.model || null,
deviceOs: deviceInfo.os || null,
targetType: targetMeta.type || null,
weapon: targetMeta.weapon || null,
distanceMeters: targetMeta.distance_meters || null,
impactsCount,
status: 'SUCCESS',
targetValid: validation.isValid ? 1 : 0,
targetStatus: validation.status,
targetConfidence: validation.confidence,
targetRingsCount: validation.ringsCount,
targetDetails: validation.details,
rawMetadata: plottingData
});
console.log(`Données reçues et sauvegardées:`);
console.log(`- Image: uploads/images/${req.file.filename}`);
console.log(`- JSON : uploads/data/${jsonFilename}`);
res.status(200).json({
code: 'UPLOAD_SUCCESS',
message: 'Photo et données uploadées avec succès',
file: {
filename: req.file.filename,
@@ -136,14 +355,254 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
},
data: {
filename: jsonFilename
}
},
target_validation: validation
});
} catch (error) {
console.error('Erreur lors de l\'upload:', error);
res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' });
logUploadEntry({
imageFilename: req.file ? req.file.filename : null,
fileSize: req.file ? req.file.size : null,
ipAddress,
userAgent,
status: 'FAILED',
errorMessage: error.message || 'Erreur interne lors de l\'upload'
});
res.status(500).json({
code: 'SERVER_ERROR',
error: error.message || 'Erreur interne du serveur lors de l\'upload'
});
}
});
// Routes de Modération : Gestion des bannissements de wallets
app.get('/api/moderation/banned', (req, res) => {
db.all('SELECT * FROM banned_wallets ORDER BY banned_at DESC', [], (err, rows) => {
if (err) {
console.error("Erreur lecture wallets bannis:", err);
return res.status(500).json({ error: 'Erreur lecture wallets bannis' });
}
res.json({ banned: rows || [] });
});
});
app.post('/api/moderation/ban', (req, res) => {
const { wallet_hash, reason, banned_by } = req.body;
if (!wallet_hash) {
return res.status(400).json({ error: 'wallet_hash obligatoire' });
}
db.run(
`INSERT INTO banned_wallets (wallet_hash, reason, banned_by, banned_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(wallet_hash) DO UPDATE SET
reason = excluded.reason,
banned_at = CURRENT_TIMESTAMP`,
[wallet_hash, reason || 'Contenu invalide ou non conforme aux règles', banned_by || 'Admin'],
function(err) {
if (err) {
console.error("Erreur bannissement:", err);
return res.status(500).json({ error: 'Erreur lors du bannissement du wallet' });
}
console.log(`[MODÉRATION] Wallet ${wallet_hash} banni (Motif: ${reason})`);
res.json({ message: 'Wallet banni avec succès', wallet_hash });
}
);
});
app.delete('/api/moderation/ban/:wallet_hash', (req, res) => {
const wallet_hash = req.params.wallet_hash;
db.run('DELETE FROM banned_wallets WHERE wallet_hash = ?', [wallet_hash], function(err) {
if (err) {
console.error("Erreur débannissement:", err);
return res.status(500).json({ error: 'Erreur lors du débannissement' });
}
console.log(`[MODÉRATION] Wallet ${wallet_hash} débanni`);
res.json({ message: 'Wallet débanni avec succès', wallet_hash });
});
});
// Route pour relancer l'analyse OpenCV sur une photo déjà existante
app.post('/api/moderation/verify/:filename', async (req, res) => {
const filename = req.params.filename;
const imgPath = path.join(imagesDir, filename);
if (!fs.existsSync(imgPath)) {
return res.status(404).json({ error: 'Image non trouvée' });
}
try {
const result = await TargetValidator.validateTarget(imgPath);
res.json({ filename, validation: result });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Route pour lister les logs avec filtres, recherche et pagination
app.get('/api/logs', (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
const wallet = req.query.wallet || req.query.wallet_hash;
const status = req.query.status;
const search = req.query.search;
let whereClauses = [];
let params = [];
if (wallet) {
whereClauses.push("wallet_hash LIKE ?");
params.push(`%${wallet}%`);
}
if (status) {
whereClauses.push("status = ?");
params.push(status);
}
if (search) {
whereClauses.push("(session_id LIKE ? OR wallet_hash LIKE ? OR device_model LIKE ? OR weapon LIKE ? OR target_type LIKE ? OR image_filename LIKE ?)");
const s = `%${search}%`;
params.push(s, s, s, s, s, s);
}
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
db.get(`SELECT COUNT(*) as total FROM upload_logs ${whereSql}`, params, (err, countRow) => {
if (err) {
console.error("Erreur comptage logs:", err);
return res.status(500).json({ error: 'Erreur lors du comptage des logs' });
}
const total = countRow ? countRow.total : 0;
const query = `
SELECT * FROM upload_logs
${whereSql}
ORDER BY timestamp DESC, id DESC
LIMIT ? OFFSET ?
`;
db.all(query, [...params, limit, offset], (err, rows) => {
if (err) {
console.error("Erreur lecture logs:", err);
return res.status(500).json({ error: 'Erreur lors de la lecture des logs' });
}
res.json({
logs: rows,
total,
limit,
offset
});
});
});
});
// Route pour les statistiques des logs
app.get('/api/logs/stats', (req, res) => {
const statsQuery = `
SELECT
COUNT(*) as total_uploads,
SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_count,
SUM(CASE WHEN date(timestamp, 'localtime') = date('now', 'localtime') THEN 1 ELSE 0 END) as today_uploads,
COUNT(DISTINCT wallet_hash) as unique_wallets,
SUM(COALESCE(file_size, 0)) as total_bytes,
MAX(timestamp) as latest_upload
FROM upload_logs
`;
db.get(statsQuery, [], (err, stats) => {
if (err) {
console.error("Erreur stats logs:", err);
return res.status(500).json({ error: 'Erreur calcul statistiques' });
}
res.json({
status: 'ok',
stats: stats || {
total_uploads: 0,
success_count: 0,
failed_count: 0,
today_uploads: 0,
unique_wallets: 0,
total_bytes: 0,
latest_upload: null
}
});
});
});
// Route pour exporter les logs (CSV ou JSON)
app.get('/api/logs/export', (req, res) => {
const format = req.query.format || 'csv';
db.all('SELECT * FROM upload_logs ORDER BY timestamp DESC, id DESC', [], (err, rows) => {
if (err) {
console.error("Erreur export logs:", err);
return res.status(500).json({ error: 'Erreur export logs' });
}
if (format === 'json') {
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.json`);
res.setHeader('Content-Type', 'application/json');
return res.send(JSON.stringify(rows, null, 2));
}
// CSV Export
const headers = [
'ID', 'Date/Heure', 'Statut', 'Wallet Hash', 'Session ID',
'Image', 'JSON', 'Taille (octets)', 'Cible', 'Arme', 'Distance (m)',
'Impacts', 'Modèle Appareil', 'OS', 'IP', 'Erreur'
];
const csvLines = [headers.join(';')];
rows.forEach(r => {
const line = [
r.id,
`"${r.timestamp || ''}"`,
`"${r.status || ''}"`,
`"${r.wallet_hash || ''}"`,
`"${r.session_id || ''}"`,
`"${r.image_filename || ''}"`,
`"${r.json_filename || ''}"`,
r.file_size || 0,
`"${r.target_type || ''}"`,
`"${r.weapon || ''}"`,
r.distance_meters || '',
r.impacts_count || 0,
`"${r.device_model || ''}"`,
`"${r.device_os || ''}"`,
`"${r.ip_address || ''}"`,
`"${(r.error_message || '').replace(/"/g, '""')}"`
];
csvLines.push(line.join(';'));
});
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.csv`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.send('\uFEFF' + csvLines.join('\r\n'));
});
});
// Route pour supprimer un log spécifique
app.delete('/api/logs/:id', (req, res) => {
const id = req.params.id;
db.run('DELETE FROM upload_logs WHERE id = ?', [id], function(err) {
if (err) {
console.error("Erreur suppression log:", err);
return res.status(500).json({ error: 'Erreur lors de la suppression' });
}
res.json({ message: 'Log supprimé avec succès', deletedId: id });
});
});
// Route pour vider les logs
app.delete('/api/logs', (req, res) => {
db.run('DELETE FROM upload_logs', [], function(err) {
if (err) {
console.error("Erreur vidage logs:", err);
return res.status(500).json({ error: 'Erreur lors de la réinitialisation des logs' });
}
res.json({ message: 'Tous les logs ont été effacés', changes: this.changes });
});
});
// Route pour récupérer toutes les photos disponibles
app.get('/api/photos', (req, res) => {
try {
@@ -164,20 +623,42 @@ app.get('/api/photos', (req, res) => {
}
});
// Route pour récupérer les statistiques d'un wallet_hash
// Route pour récupérer les statistiques d'un wallet_hash + statut de modération
app.get('/api/stats/:wallet_hash', (req, res) => {
const walletHash = req.params.wallet_hash;
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => {
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, statRow) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
}
if (row) {
res.json({ status: 'ok', stats: row });
} else {
res.json({ status: 'ok', stats: { photo_count: 0, last_upload: null } });
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (bErr, bannedRow) => {
const isBanned = !!bannedRow;
const stats = statRow || { photo_count: 0, last_upload: null };
res.json({
status: 'ok',
stats,
is_banned: isBanned,
ban_reason: bannedRow ? bannedRow.reason : null,
banned_at: bannedRow ? bannedRow.banned_at : null
});
});
});
});
// Route directe pour vérifier l'état de modération d'un wallet
app.get('/api/moderation/status/:wallet_hash', (req, res) => {
const walletHash = req.params.wallet_hash;
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, bannedRow) => {
if (err) {
return res.status(500).json({ error: 'Erreur lecture statut modération' });
}
res.json({
wallet_hash: walletHash,
is_banned: !!bannedRow,
ban_reason: bannedRow ? bannedRow.reason : null,
banned_at: bannedRow ? bannedRow.banned_at : null
});
});
});
+230
View File
@@ -0,0 +1,230 @@
const cvPromise = require('@techstark/opencv-js');
const sharp = require('sharp');
let cvInstance = null;
async function getCV() {
if (!cvInstance) {
cvInstance = await cvPromise;
}
return cvInstance;
}
/**
* Service de validation OpenCV pour vérifier si une image reçue est bien une cible de tir.
*/
class TargetValidator {
/**
* Analyse une image et renvoie le diagnostic OpenCV
* @param {string|Buffer} imageInput - Chemin du fichier ou Buffer de l'image
* @returns {Promise<{
* isValid: boolean,
* status: 'VALID' | 'SUSPICIOUS' | 'INVALID',
* confidence: number,
* ringsCount: number,
* bestCenter: { x: number, y: number, radius: number } | null,
* details: string
* }>}
*/
static async validateTarget(imageInput) {
const cv = await getCV();
let src = null;
let gray = null;
let blurred = null;
let circles = null;
try {
// 1. Charger et décoder l'image avec Sharp pour obtenir les pixels bruts RGBA
const { data, info } = await sharp(imageInput)
.ensureAlpha() // 4 canaux RGBA
.raw()
.toBuffer({ resolveWithObject: true });
const width = info.width;
const height = info.height;
const minDim = Math.min(width, height);
// 2. Créer une Mat OpenCV à partir des pixels RGBA
src = new cv.Mat(height, width, cv.CV_8UC4);
src.data.set(data);
// 3. Conversion en niveaux de gris
gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
// 4. Flou Gaussien pour réduire le bruit
blurred = new cv.Mat();
const ksize = new cv.Size(9, 9);
cv.GaussianBlur(gray, blurred, ksize, 2, 2, cv.BORDER_DEFAULT);
// 5. Détection multi-bandes pour capturer tous les anneaux concentriques
// (Puisque minDist dans HoughCircles empêche la détection de cercles concentriques sur un seul passage)
const detectedCircles = [];
const bands = [
{ min: Math.floor(minDim * 0.04), max: Math.floor(minDim * 0.18), p2: 45 },
{ min: Math.floor(minDim * 0.18), max: Math.floor(minDim * 0.35), p2: 48 },
{ min: Math.floor(minDim * 0.35), max: Math.floor(minDim * 0.55), p2: 50 }
];
circles = new cv.Mat();
for (const band of bands) {
cv.HoughCircles(
blurred,
circles,
cv.HOUGH_GRADIENT,
1,
Math.max(Math.floor(minDim * 0.05), 10),
100,
band.p2,
band.min,
band.max
);
if (circles.cols > 0 && circles.data32F) {
for (let i = 0; i < circles.cols; i++) {
const x = circles.data32F[i * 3];
const y = circles.data32F[i * 3 + 1];
const r = circles.data32F[i * 3 + 2];
if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
detectedCircles.push({ x, y, r });
}
}
}
}
// Si aucun cercle dans les bandes, tentative globale tolérante
if (detectedCircles.length === 0) {
cv.HoughCircles(
blurred,
circles,
cv.HOUGH_GRADIENT,
1,
Math.max(Math.floor(minDim * 0.08), 10),
100,
35,
Math.floor(minDim * 0.05),
Math.floor(minDim * 0.55)
);
if (circles.cols > 0 && circles.data32F) {
for (let i = 0; i < circles.cols; i++) {
const x = circles.data32F[i * 3];
const y = circles.data32F[i * 3 + 1];
const r = circles.data32F[i * 3 + 2];
if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
detectedCircles.push({ x, y, r });
}
}
}
}
// Si aucun cercle détecté
if (detectedCircles.length === 0) {
return {
isValid: false,
status: 'INVALID',
confidence: 0.02,
ringsCount: 0,
bestCenter: null,
details: 'Aucun motif circulaire ou cible détecté sur cette photo.'
};
}
// 6. Clustering des cercles pour trouver les anneaux concentriques
const tolerance = minDim * 0.07; // Tolérance de 7% pour le décalage de centre
const clusters = [];
for (const circle of detectedCircles) {
let added = false;
for (const cluster of clusters) {
// Prendre le centre moyen actuel du cluster
const avgClusterX = cluster.reduce((sum, c) => sum + c.x, 0) / cluster.length;
const avgClusterY = cluster.reduce((sum, c) => sum + c.y, 0) / cluster.length;
const dist = Math.sqrt(Math.pow(circle.x - avgClusterX, 2) + Math.pow(circle.y - avgClusterY, 2));
if (dist < tolerance) {
// Vérifier que le rayon n'est pas un doublon exact (< 10px de différence)
const isDuplicate = cluster.some(c => Math.abs(c.r - circle.r) < minDim * 0.03);
if (!isDuplicate) {
cluster.push(circle);
}
added = true;
break;
}
}
if (!added) {
clusters.push([circle]);
}
}
// Trier les clusters par nombre de cercles concentriques décroissant
clusters.sort((a, b) => b.length - a.length);
const bestCluster = clusters[0];
const ringsCount = bestCluster.length;
// Calculer le centre moyen du meilleur cluster
const avgX = bestCluster.reduce((acc, c) => acc + c.x, 0) / ringsCount;
const avgY = bestCluster.reduce((acc, c) => acc + c.y, 0) / ringsCount;
const maxR = Math.max(...bestCluster.map(c => c.r));
// 7. Évaluation du statut et de la confiance
if (ringsCount >= 3) {
return {
isValid: true,
status: 'VALID',
confidence: Math.min(0.92 + (ringsCount - 3) * 0.02, 0.99),
ringsCount,
bestCenter: { x: avgX, y: avgY, radius: maxR },
details: `Cible certifiée : ${ringsCount} anneaux concentriques identifiés.`
};
} else if (ringsCount === 2) {
return {
isValid: true,
status: 'VALID',
confidence: 0.85,
ringsCount,
bestCenter: { x: avgX, y: avgY, radius: maxR },
details: `Cible confirmée : ${ringsCount} anneaux concentriques détectés.`
};
} else if (ringsCount === 1) {
return {
isValid: true,
status: 'SUSPICIOUS',
confidence: 0.50,
ringsCount: 1,
bestCenter: { x: avgX, y: avgY, radius: maxR },
details: 'Un seul anneau détecté, confirmation visuelle recommandée.'
};
} else {
return {
isValid: false,
status: 'INVALID',
confidence: 0.1,
ringsCount: 0,
bestCenter: null,
details: 'Motif de cible non conforme.'
};
}
} catch (error) {
console.error('Erreur TargetValidator OpenCV:', error);
return {
isValid: false,
status: 'INVALID',
confidence: 0.0,
ringsCount: 0,
bestCenter: null,
details: `Erreur d'analyse OpenCV: ${error.message}`
};
} finally {
// 8. Nettoyage mémoire
if (src && !src.isDeleted()) src.delete();
if (gray && !gray.isDeleted()) gray.delete();
if (blurred && !blurred.isDeleted()) blurred.delete();
if (circles && !circles.isDeleted()) circles.delete();
}
}
}
module.exports = TargetValidator;
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

+19
View File
@@ -0,0 +1,19 @@
services:
backendia:
build:
context: ./backendia
dockerfile: Dockerfile
container_name: backendia-prod
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
volumes:
- backendia_uploads:/app/uploads
- backendia_exports:/app/exports
volumes:
backendia_uploads:
backendia_exports:
+1 -2
View File
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart';
import 'core/theme/app_theme.dart';
import 'main_navigation_holder.dart';
import 'features/home/home_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class BullyApp extends StatelessWidget {
@@ -28,7 +27,7 @@ class BullyApp extends StatelessWidget {
Locale('fr', 'FR'), // 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'),
),
],
),
);
}
}
+92 -10
View File
@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/session.dart';
@@ -11,6 +10,12 @@ import '../../core/constants/app_constants.dart';
class DatabaseHelper {
static DatabaseHelper? _instance;
static Database? _database;
// On met en cache le Future d'initialisation (et non la Database résolue)
// pour éviter qu'un démarrage concurrent (les 4 onglets de l'IndexedStack
// interrogent la base en même temps) ne lance plusieurs _initDatabase() en
// parallèle. Sur une base fraîche, cela dédoublait onCreate et rendait la
// toute première écriture peu fiable.
static Future<Database>? _initFuture;
DatabaseHelper._internal();
@@ -20,7 +25,9 @@ class DatabaseHelper {
}
Future<Database> get database async {
_database ??= await _initDatabase();
if (_database != null) return _database!;
_initFuture ??= _initDatabase();
_database = await _initFuture!;
return _database!;
}
@@ -373,17 +380,71 @@ class DatabaseHelper {
limit: limit,
offset: offset,
);
if (sessionMaps.isEmpty) return [];
final sessions = <Session>[];
for (final sessionMap in sessionMaps) {
final sessionId = sessionMap['id'] as String;
final session = await getSession(sessionId);
if (session != null) {
sessions.add(session);
}
// 3 requêtes groupées au lieu d'une cascade par session puis par analyse
// (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
// ce qui ralentissait l'accueil/historique/stats au fil des mois.
final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
final analysisMaps = await _queryIn(
db,
AppConstants.targetAnalysesTable,
'session_id',
sessionIds,
);
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
final shotMaps = await _queryIn(
db,
AppConstants.shotsTable,
'analysis_id',
analysisIds,
);
final shotsByAnalysis = <String, List<Shot>>{};
for (final map in shotMaps) {
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
.add(Shot.fromMap(map));
}
return sessions;
final analysesBySession = <String, List<TargetAnalysis>>{};
for (final map in analysisMaps) {
final analysis = TargetAnalysis.fromMap(
map,
shotsByAnalysis[map['id'] as String] ?? [],
);
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
}
return sessionMaps
.map((m) =>
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
.toList();
}
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
Future<List<Map<String, Object?>>> _queryIn(
Database db,
String table,
String column,
List<String> values,
) async {
if (values.isEmpty) return [];
const chunkSize = 500;
final results = <Map<String, Object?>>[];
for (var i = 0; i < values.length; i += chunkSize) {
final chunk = values.sublist(
i,
i + chunkSize > values.length ? values.length : i + chunkSize,
);
final placeholders = List.filled(chunk.length, '?').join(',');
results.addAll(await db.query(
table,
where: '$column IN ($placeholders)',
whereArgs: chunk,
));
}
return results;
}
Future<int> deleteSession(String id) async {
@@ -510,6 +571,16 @@ class DatabaseHelper {
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> getSessionCountForWeapon(String weaponId) async {
final db = await database;
final result = await db.rawQuery('''
SELECT COUNT(id) as count
FROM ${AppConstants.sessionsTable}
WHERE weapon_id = ?
''', [weaponId]);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> insertMaintenance(MaintenanceEntry entry) async {
final db = await database;
return await db.insert(
@@ -519,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 {
final db = await database;
final maps = await db.query(
@@ -543,5 +624,6 @@ class DatabaseHelper {
final db = await database;
await db.close();
_database = null;
_initFuture = null;
}
}
+44 -1
View File
@@ -131,6 +131,29 @@ class SessionRepository {
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() {
return _uuid.v4();
}
@@ -170,6 +193,11 @@ class SessionRepository {
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 {
await _databaseHelper.updateWeapon(weapon);
}
@@ -182,18 +210,23 @@ class SessionRepository {
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
}
Future<int> getSessionCountForWeapon(String weaponId) async {
return await _databaseHelper.getSessionCountForWeapon(weaponId);
}
Future<void> addMaintenanceEntry({
required String weaponId,
required MaintenanceType type,
required String description,
int? roundsSinceLast,
DateTime? date,
}) async {
final entry = MaintenanceEntry(
id: _uuid.v4(),
weaponId: weaponId,
type: type,
description: description,
date: DateTime.now(),
date: date ?? DateTime.now(),
roundsSinceLastMaintenance: roundsSinceLast,
);
await _databaseHelper.insertMaintenance(entry);
@@ -203,6 +236,16 @@ class SessionRepository {
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 {
await _databaseHelper.deleteMaintenance(id);
}
+35 -485
View File
@@ -1,8 +1,8 @@
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
///
/// Gère le workflow complet d'analyse : chargement d'image, détection de cible,
/// gestion des impacts (manuels et automatiques), calcul des scores,
/// analyse de groupement et sauvegarde des sessions.
/// Gère le workflow complet d'analyse : chargement d'image, gestion des
/// impacts placés manuellement, calcul des scores, analyse de groupement
/// et sauvegarde des sessions.
library;
import 'dart:io';
@@ -13,37 +13,25 @@ import '../../data/models/target_analysis.dart';
import '../../data/models/shot.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../../services/distortion_correction_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/ai_export_service.dart';
enum AnalysisState { initial, loading, success, error }
class AnalysisProvider extends ChangeNotifier {
final TargetDetectionService _detectionService;
final ScoreCalculatorService _scoreCalculatorService;
final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository;
final DistortionCorrectionService _distortionService;
final OpenCVTargetService _opencvTargetService;
final Uuid _uuid = const Uuid();
AnalysisProvider({
required TargetDetectionService detectionService,
required ScoreCalculatorService scoreCalculatorService,
required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository,
DistortionCorrectionService? distortionService,
OpenCVTargetService? opencvTargetService,
}) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService,
}) : _scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository,
_distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
_sessionRepository = sessionRepository;
AnalysisState _state = AnalysisState.initial;
String? _errorMessage;
@@ -53,7 +41,7 @@ class AnalysisProvider extends ChangeNotifier {
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
double _cropRotation = 0.0;
// Target detection results
// Target calibration
double _targetCenterX = 0.5;
double _targetCenterY = 0.5;
double _targetRadius = 0.4;
@@ -71,15 +59,6 @@ class AnalysisProvider extends ChangeNotifier {
// Grouping results
GroupingResult? _groupingResult;
// Reference-based detection
List<Shot> _referenceImpacts = [];
ImpactCharacteristics? _learnedCharacteristics;
// Distortion correction
bool _distortionCorrectionEnabled = false;
DistortionParameters? _distortionParams;
String? _correctedImagePath;
// Getters
AnalysisState get state => _state;
String? get errorMessage => _errorMessage;
@@ -100,21 +79,6 @@ class AnalysisProvider extends ChangeNotifier {
int get totalScore => _scoreResult?.totalScore ?? 0;
int get shotCount => _shots.length;
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
// Distortion correction getters
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
DistortionParameters? get distortionParams => _distortionParams;
String? get correctedImagePath => _correctedImagePath;
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath
: _imagePath;
/// Modifie et mémorise la rotation de l'image pour le Plotting
void setCropRotation(double rotation) {
@@ -122,14 +86,11 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Analyze an image
///
/// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set.
/// Charge l'image et initialise les paramètres de cible par défaut.
/// Le placement des impacts et la calibration se font ensuite manuellement.
Future<void> analyzeImage(
String imagePath,
TargetType targetType, {
bool autoAnalyze = true,
Offset? manualCenter,
}) async {
_state = AnalysisState.loading;
@@ -147,55 +108,13 @@ class AnalysisProvider extends ChangeNotifier {
_imageAspectRatio = frame.image.width / frame.image.height;
frame.image.dispose();
if (!autoAnalyze) {
// Just setup default values without running detection
_targetCenterX = manualCenter?.dx ?? 0.5;
_targetCenterY = manualCenter?.dy ?? 0.5;
_targetRadius = 0.4;
_targetInnerRadius = 0.04;
// Initialize empty shots list
_shots = [];
_state = AnalysisState.success;
notifyListeners();
return;
}
final result = await _detectionService.detectTargetAsync(
imagePath,
targetType,
);
if (!result.success) {
_state = AnalysisState.error;
_errorMessage = result.errorMessage;
notifyListeners();
return;
}
_targetCenterX = result.centerX;
_targetCenterY = result.centerY;
_targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts
_shots = result.impacts.map((impact) {
return Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
analysisId: '',
);
}).toList();
// Calculate scores
_recalculateScores();
// Calculate grouping
_recalculateGrouping();
_state = AnalysisState.success;
notifyListeners();
} catch (e) {
@@ -216,22 +135,18 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Remove a shot
void removeShot(String shotId) {
_shots.removeWhere((shot) => shot.id == shotId);
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Synthèse).
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
void clearShots() {
_shots.clear();
_recalculateScores();
_recalculateGrouping();
notifyListeners();
}
/// Move a shot to a new position
void moveShot(String shotId, double newX, double newY) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
final newScore = _calculateShotScore(newX, newY);
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
/// Remove a shot
void removeShot(String shotId) {
_shots.removeWhere((shot) => shot.id == shotId);
_recalculateScores();
_recalculateGrouping();
notifyListeners();
@@ -247,265 +162,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({
int darkThreshold = 80,
int minImpactSize = 20,
int maxImpactSize = 500,
double minCircularity = 0.6,
double minFillRatio = 0.5,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = ImpactDetectionSettings(
darkThreshold: darkThreshold,
minImpactSize: minImpactSize,
maxImpactSize: maxImpactSize,
minCircularity: minCircularity,
minFillRatio: minFillRatio,
);
final detectedImpacts = _detectionService.detectImpactsOnly(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
Future<int> autoDetectImpactsWithOpenCV({
double cannyThreshold1 = 50,
double cannyThreshold2 = 150,
double minDist = 20,
double param1 = 100,
double param2 = 30,
int minRadius = 5,
int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5,
bool useContourDetection = true,
double minCircularity = 0.6,
double minContourArea = 50,
double maxContourArea = 5000,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = OpenCVDetectionSettings(
cannyThreshold1: cannyThreshold1,
cannyThreshold2: cannyThreshold2,
minDist: minDist,
param1: param1,
param2: param2,
minRadius: minRadius,
maxRadius: maxRadius,
blurSize: blurSize,
useContourDetection: useContourDetection,
minCircularity: minCircularity,
minContourArea: minContourArea,
maxContourArea: maxContourArea,
);
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings: settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Detect impacts with OpenCV using reference points
Future<int> detectFromReferencesWithOpenCV({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_referenceImpacts.length < 2) {
return 0;
}
// Convertir les références
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
references,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
_referenceImpacts.add(shot);
notifyListeners();
}
/// Remove a reference impact
void removeReferenceImpact(String shotId) {
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
_learnedCharacteristics = null;
notifyListeners();
}
/// Clear all reference impacts
void clearReferenceImpacts() {
_referenceImpacts.clear();
_learnedCharacteristics = null;
notifyListeners();
}
/// Learn characteristics from reference impacts
bool learnFromReferences() {
if (_imagePath == null || _referenceImpacts.length < 2) return false;
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
_imagePath!,
references,
);
notifyListeners();
return _learnedCharacteristics != null;
}
/// Auto-detect impacts using learned reference characteristics
Future<int> detectFromReferences({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_learnedCharacteristics == null) {
return 0;
}
final detectedImpacts = _detectionService.detectImpactsFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
_learnedCharacteristics!,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
analysisId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Adjust target position
void adjustTargetPosition(
double centerX,
@@ -539,118 +195,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio = 1.0;
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
debugPrint('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
notifyListeners();
}
/// Applique la correction de distorsion à l'image
/// Crée une nouvelle image corrigée et la sauvegarde
Future<void> applyDistortionCorrection() async {
if (_imagePath == null || _distortionParams == null) return;
try {
_correctedImagePath = await _distortionService.applyCorrection(
_imagePath!,
_distortionParams!,
);
_distortionCorrectionEnabled = true;
notifyListeners();
} catch (e) {
_errorMessage = 'Erreur lors de la correction: $e';
notifyListeners();
}
}
/// Active ou désactive l'affichage de l'image corrigée
void setDistortionCorrectionEnabled(bool enabled) {
if (enabled && _correctedImagePath == null && _distortionParams != null) {
// Si on active mais pas encore d'image corrigée, la créer
applyDistortionCorrection();
} else {
_distortionCorrectionEnabled = enabled;
notifyListeners();
}
}
/// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel)
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
// 2. Vérification si une correction est réellement nécessaire
if (_distortionParams != null && _distortionParams!.needsCorrection) {
// 3. Application immédiate de la transformation (méthode asynchrone)
await applyDistortionCorrection();
} else {
notifyListeners();
}
}
Future<void> runFullDistortionWorkflow() async {
_state = AnalysisState.loading;
notifyListeners();
try {
calculateDistortion();
await applyDistortionCorrection();
_distortionCorrectionEnabled = true;
_state = AnalysisState.success;
} catch (e) {
_errorMessage = "Erreur de rendu : $e";
_state = AnalysisState.error;
} finally {
notifyListeners();
}
}
int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) {
return _scoreCalculatorService.calculateConcentricScore(
@@ -694,12 +238,21 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
}
/// Exporte l'image et le json vers le backend IA
Future<bool> exportToAiBackend() async {
/// Exporte l'image et le json vers le backend IA.
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
Future<AiExportResult> exportToAiBackend({
String? sessionId,
int? distance,
String? weapon,
}) async {
if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
notifyListeners();
return false;
return AiExportResult.error(
code: 'MISSING_DATA',
message: "Impossible d'exporter : image ou type de cible manquant.",
);
}
final service = AiExportService();
@@ -707,22 +260,24 @@ class AnalysisProvider extends ChangeNotifier {
_state = AnalysisState.loading;
notifyListeners();
final success = await service.exportData(
final result = await service.exportData(
imagePath: _imagePath!,
sessionId: 'export',
sessionId: sessionId ?? 'export',
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
shots: _shots,
distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown',
);
_state = AnalysisState.success;
if (!success) {
_errorMessage = "Échec de l'export vers le serveur IA.";
if (!result.isSuccess) {
_errorMessage = result.message;
}
notifyListeners();
return success;
return result;
}
/// Save the session
@@ -807,11 +362,6 @@ class AnalysisProvider extends ChangeNotifier {
_shots = [];
_scoreResult = null;
_groupingResult = null;
_referenceImpacts = [];
_learnedCharacteristics = null;
_distortionCorrectionEnabled = false;
_distortionParams = null;
_correctedImagePath = null;
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 '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../services/grouping_analyzer_service.dart';
class GroupingStats extends StatelessWidget {
@@ -43,32 +44,65 @@ class GroupingStats extends StatelessWidget {
fontWeight: FontWeight.bold,
),
),
const MetricInfoButton(
title: 'Groupement',
explanations: [
MetricExplanation(
'Étalement',
'Distance entre vos deux impacts les plus éloignés, '
'exprimée en % de la largeur de l\'image. Plus c\'est '
'bas, plus le groupement est serré.',
),
MetricExplanation(
'Dispersion',
'Régularité des impacts autour de leur centre commun '
'(écart-type). Plus c\'est bas, plus vos tirs sont '
'réguliers.',
),
MetricExplanation(
'Décalage',
'Direction du centre de votre groupement par rapport au '
'centre de la cible (ex. « Droite » = vos tirs sont '
'globalement décalés vers la droite).',
),
MetricExplanation(
'Étoiles',
'Qualité globale du groupement, de ★ (à améliorer) à '
'★★★★★ (excellent).',
),
],
),
const Spacer(),
_buildQualityBadge(context),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStat(
Expanded(
child: _buildStat(
context,
'Diametre',
'Étalement',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
_buildStat(
),
Expanded(
child: _buildStat(
context,
'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
_buildStat(
),
Expanded(
child: _buildStat(
context,
'Decalage',
'Décalage',
offsetDescription,
icon: Icons.compare_arrows,
),
),
],
),
const SizedBox(height: 12),
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
const SizedBox(height: 4),
Text(
value,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre';
return 'Centré';
}
String vertical = '';
String horizontal = '';
if (offsetY < -0.02) {
vertical = 'H';
vertical = 'Haut';
} else if (offsetY > 0.02) {
vertical = 'B';
vertical = 'Bas';
}
if (offsetX < -0.02) {
horizontal = 'G';
horizontal = 'Gauche';
} else if (offsetX > 0.02) {
horizontal = 'D';
horizontal = 'Droite';
}
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
+72 -1
View File
@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/metric_info_button.dart';
import '../../../data/models/target_type.dart';
import '../../../services/score_calculator_service.dart';
@@ -16,12 +17,22 @@ class ScoreCard extends StatelessWidget {
final ScoreResult? scoreResult;
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({
super.key,
required this.totalScore,
required this.shotCount,
this.scoreResult,
required this.targetType,
this.sessionTotalScore,
this.sessionTargetCount = 0,
});
@override
@@ -44,6 +55,41 @@ class ScoreCard extends StatelessWidget {
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(),
@@ -67,11 +113,12 @@ class ScoreCard extends StatelessWidget {
shotCount > 0
? (totalScore / shotCount).toStringAsFixed(1)
: '-',
subtitle: '/ $maxScore',
),
if (scoreResult != null)
_buildScoreStat(
context,
'Pourcentage',
'Réussite',
'${scoreResult!.percentage.toStringAsFixed(0)}%',
),
],
@@ -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(
BuildContext context,
String label,
@@ -4,9 +4,8 @@
/// Les anneaux sont répartis proportionnellement.
library;
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart';
class TargetCalibration extends StatefulWidget {
@@ -44,6 +43,15 @@ class TargetCalibration extends StatefulWidget {
}
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 _centerY;
late double _radius;
@@ -83,6 +91,16 @@ class TargetCalibrationState extends State<TargetCalibration> {
_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}) {
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
@@ -121,7 +139,14 @@ class TargetCalibrationState extends State<TargetCalibration> {
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii && widget.initialRingRadii != null) {
// On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
// réellement d'une nouvelle détection (ils diffèrent de notre état courant).
// Sinon il s'agit de l'écho de notre propre _notifyChange (aller-retour via
// le provider) : le clobber effacerait le profil d'origine et casserait le
// bouton de réinitialisation de l'espacement.
if (widget.initialRingRadii != oldWidget.initialRingRadii &&
widget.initialRingRadii != null &&
!listEquals(widget.initialRingRadii, _ringRadii)) {
_originalRingRadii = List.from(widget.initialRingRadii!);
shouldReinit = true;
}
@@ -137,9 +162,9 @@ class TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) {
final size = constraints.biggest;
return Stack(
children: [
GestureDetector(
// Les réglages (taille / espacement) sont rendus par l'écran hôte
// AU-DESSUS de l'image : rien ne vient masquer la cible ici.
return GestureDetector(
onScaleStart: (details) {
_baseRadiusBeforeScale = _radius;
final tapX = details.localFocalPoint.dx / size.width;
@@ -169,125 +194,68 @@ class TargetCalibrationState extends State<TargetCalibration> {
isDraggingInnerRadius: false,
),
),
),
);
},
);
}
Positioned(
top: 10,
left: 40,
right: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Options d\'espacement avancées',
style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500),
),
SizedBox(
height: 28,
child: Switch(
value: _showEspacement,
activeThumbColor: const Color(0xFF00FF00),
onChanged: (bool value) {
// ---------------------------------------------------------------------------
// 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(() {
_showEspacement = value;
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine !
if (!value) {
_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();
},
),
),
],
),
),
}
// Slider pour la taille (toujours visible)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
Expanded(
child: Slider(
// SÉCURITÉ : Ouverture mesurée des bornes pour plus de liberté sans débordement incontrôlé
value: _radius.clamp(0.3, 0.95),
min: 0.3,
max: 0.95,
activeColor: AppTheme.primaryColor,
onChanged: (value) {
/// Applique un nouvel espacement entre les anneaux.
void setSpacingRatio(double value) {
setState(() {
_radius = value;
_innerRadius = _radius * _currentEspacementRatio;
// CORRECTION : Si le mode avancé n'est pas coché, on applique la taille pure sans détruire le ratio d'origine
_initRingRadii(forceRecalculate: _showEspacement);
});
_notifyChange();
},
),
),
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
],
),
),
// Affichage conditionnel du slider d'espacement orange
if (_showEspacement) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.compress, color: Colors.white, size: 16),
Expanded(
child: Row(
children: [
Expanded(
child: Slider(
value: _currentEspacementRatio,
min: 0.01,
// SÉCURITÉ : Écartement max bridé à 0.70 pour confiner le dernier cercle
max: 0.70,
activeColor: Colors.orange,
onChanged: (value) {
setState(() {
_currentEspacementRatio = value;
_currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.expand, color: Colors.white, size: 16),
],
),
),
// CORRECTION DU BOUTON RESET : Restaure désormais le vrai profil d'usine de l'IA
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
onPressed: () {
}
/// Restaure le vrai profil d'usine détecté sur l'image.
void resetSpacing() {
setState(() {
if (_originalRingRadii != null) {
_initRingRadii(forceRecalculate: false);
@@ -302,22 +270,6 @@ class TargetCalibrationState extends State<TargetCalibration> {
}
});
_notifyChange();
},
tooltip: 'Réinitialiser l\'espacement',
constraints: const BoxConstraints(),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
],
),
),
],
],
),
),
],
);
},
);
}
Widget buildDirectionalControls(BuildContext context, Size size) {
@@ -328,7 +280,13 @@ class TargetCalibrationState extends State<TargetCalibration> {
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Column(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Réduit la cible d'un pixel.
_buildSizeButton('', () => adjustRadiusByPixels(-1, size)),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
@@ -343,6 +301,28 @@ class TargetCalibrationState extends State<TargetCalibration> {
_buildDirectionButton(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',
),
);
}
@@ -381,7 +361,7 @@ class TargetCalibrationState extends State<TargetCalibration> {
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
setState(() {
if (details.pointerCount == 2) {
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95);
_radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius);
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: _showEspacement);
} else if (_isDraggingCenter) {
@@ -1,8 +1,9 @@
/// Overlay visuel de la cible.
///
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout
/// d'impacts et la sélection d'impacts existants.
/// Dessine les anneaux de la cible, les impacts et le cercle de groupement.
/// Gère uniquement la SÉLECTION d'impacts existants (tap sur un impact).
/// L'AJOUT d'un impact est délégué à l'écran parent pour éviter tout conflit
/// de gestes avec le zoom/pan de l'InteractiveViewer.
library;
import 'package:flutter/material.dart';
@@ -19,11 +20,9 @@ class TargetOverlay extends StatelessWidget {
final int ringCount;
final List<double>? ringRadii;
final void Function(Shot shot)? onShotTapped;
final void Function(double x, double y)? onAddShot;
final double? groupingCenterX;
final double? groupingCenterY;
final double? groupingDiameter;
final List<Shot>? referenceImpacts;
final double zoomScale;
final bool showRings;
@@ -37,29 +36,29 @@ class TargetOverlay extends StatelessWidget {
this.ringCount = 10,
this.ringRadii,
this.onShotTapped,
this.onAddShot,
this.groupingCenterX,
this.groupingCenterY,
this.groupingDiameter,
this.referenceImpacts,
this.zoomScale = 1.0,
this.showRings = false,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapUp: (details) {
if (onAddShot != null) {
// Utiliser les constraints pour un calcul précis
final relX = details.localPosition.dx / constraints.maxWidth;
final relY = details.localPosition.dy / constraints.maxHeight;
onAddShot!(relX, relY);
}
},
// IMPORTANT : plus de GestureDetector global ici.
// L'ancien GestureDetector (onTapUp couvrant toute la surface, en
// HitTestBehavior.translucent) volait les pointeurs au pinch de
// l'InteractiveViewer parent et rendait le zoom capricieux.
//
// Désormais :
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
// zones de tap localisées (deferToChild) placées sur chaque impact —
// 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(
painter: _TargetOverlayPainter(
shots: shots,
@@ -72,52 +71,47 @@ class TargetOverlay extends StatelessWidget {
groupingCenterX: groupingCenterX,
groupingCenterY: groupingCenterY,
groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts,
zoomScale: zoomScale,
showRings: showRings,
),
child: Stack(
children: shots.map((shot) {
return Positioned(
left: 0,
top: 0,
right: 0,
bottom: 0,
child: LayoutBuilder(
builder: (context, innerConstraints) {
final x = shot.x * innerConstraints.maxWidth;
final y = shot.y * innerConstraints.maxHeight;
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran)
builder: (context, constraints) {
final onTapped = onShotTapped;
if (onTapped == null) return const SizedBox.expand();
return Stack(
children: shots.map((shot) {
final x = shot.x * constraints.maxWidth;
final y = shot.y * constraints.maxHeight;
// Zone de tap qui reste constante à l'écran malgré le zoom.
final tapSize = 30 / zoomScale;
final halfTapSize = tapSize / 2;
return Stack(
children: [
Positioned(
return Positioned(
left: x - halfTapSize,
top: y - halfTapSize,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => onShotTapped?.call(shot),
// deferToChild : ne capte le toucher QUE sur la zone du
// Container (un cercle opaque au hit-test), pas ailleurs.
// Le reste de la surface reste donc disponible pour le
// pinch/pan de l'InteractiveViewer.
behavior: HitTestBehavior.deferToChild,
onTap: () => onTapped(shot),
child: Container(
width: tapSize,
height: tapSize,
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,
),
),
),
),
],
);
},
),
);
}).toList(),
),
),
);
},
),
),
);
}
}
@@ -133,7 +127,6 @@ class _TargetOverlayPainter extends CustomPainter {
final double? groupingCenterX;
final double? groupingCenterY;
final double? groupingDiameter;
final List<Shot>? referenceImpacts;
final double zoomScale;
final bool showRings;
@@ -148,7 +141,6 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingCenterX,
this.groupingCenterY,
this.groupingDiameter,
this.referenceImpacts,
this.zoomScale = 1.0,
this.showRings = false,
});
@@ -169,13 +161,6 @@ class _TargetOverlayPainter extends CustomPainter {
for (final shot in shots) {
_drawImpact(canvas, size, shot);
}
// Draw reference impacts (with different color)
if (referenceImpacts != null) {
for (final ref in referenceImpacts!) {
_drawReferenceImpact(canvas, size, ref);
}
}
}
void _drawTargetCenter(Canvas canvas, Size size) {
@@ -292,16 +277,17 @@ class _TargetOverlayPainter extends CustomPainter {
final strokeWidth = 3 / zoomScale;
final fontSize = 10 / zoomScale;
// Draw outer circle (white outline for visibility)
// Draw outer circle (white outline for visibility) — gardé OPAQUE pour
// bien repérer le centre même quand le remplissage est transparent.
final outlinePaint = Paint()
..color = AppTheme.impactOutlineColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
// Draw impact marker
// Draw impact marker — TRANSPARENCE 30% pour voir l'impact réel derrière
final impactPaint = Paint()
..color = AppTheme.impactColor
..color = AppTheme.impactColor.withValues(alpha: 0.3)
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
@@ -324,48 +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
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
return shots != oldDelegate.shots ||
@@ -377,7 +321,6 @@ class _TargetOverlayPainter extends CustomPainter {
groupingCenterX != oldDelegate.groupingCenterX ||
groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts ||
zoomScale != oldDelegate.zoomScale ||
showRings != oldDelegate.showRings;
}
File diff suppressed because it is too large Load Diff
+207 -147
View File
@@ -9,28 +9,17 @@ import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget {
/// Image affichée/recadrée dans cet écran. Au tout premier passage, c'est
/// aussi l'image originale. Lors des allers-retours, on recharge toujours
/// l'ORIGINAL ici (jamais une image déjà recadrée) pour éviter le zoom
/// cumulatif.
final String imagePath;
/// Chemin de l'image ORIGINALE (jamais recadrée). Si null, [imagePath] est
/// considéré comme l'original (premier passage depuis la caméra/galerie).
final String? originalImagePath;
final TargetType targetType;
/// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement
/// PAS restauré : on repart toujours de l'image entière.
final double? initialRotation;
final double? initialScale;
final Offset? initialOffset;
const CropScreen({
super.key,
required this.imagePath,
this.originalImagePath,
required this.targetType,
this.initialRotation,
this.initialScale,
this.initialOffset,
});
@override
@@ -38,6 +27,11 @@ class CropScreen extends StatefulWidget {
}
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();
// États de transformation
@@ -56,20 +50,14 @@ class _CropScreenState extends State<CropScreen> {
late Size _viewportSize;
late double _cropSize;
/// L'image effectivement travaillée par cet écran : toujours l'originale.
String get _workingImagePath =>
widget.originalImagePath ?? widget.imagePath;
@override
void initState() {
super.initState();
// On restaure uniquement la rotation (pas le zoom).
_rotation = widget.initialRotation ?? 0.0;
_loadImageInfo();
}
Future<void> _loadImageInfo() async {
final file = File(_workingImagePath);
final file = File(widget.imagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) {
setState(() {
@@ -118,10 +106,44 @@ class _CropScreenState extends State<CropScreen> {
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
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(
child: Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
@@ -132,31 +154,15 @@ class _CropScreenState extends State<CropScreen> {
),
),
),
),
),
),
// TEXTE D'AIDE AJUSTÉ
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
const SizedBox(width: 8),
Text(
'Alignez et pivotez la cible sur la croix',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
const SizedBox(height: 4),
Text(
'Glissez à un doigt pour déplacer, pincez pour zoomer',
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
),
],
),
),
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),
@@ -180,12 +186,13 @@ class _CropScreenState extends State<CropScreen> {
),
Row(
children: [
const Icon(Icons.rotate_left, color: Colors.white38, size: 20),
// Les icônes de sens de rotation ont rejoint les boutons / +
// de la croix directionnelle.
Expanded(
child: Slider(
value: _rotation,
min: -15.0,
max: 15.0,
min: _minRotation,
max: _maxRotation,
divisions: 300,
label: '${_rotation.toStringAsFixed(1)}°',
activeColor: const Color(0xFF1A73E8),
@@ -197,7 +204,6 @@ class _CropScreenState extends State<CropScreen> {
},
),
),
const Icon(Icons.rotate_right, color: Colors.white38, size: 20),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
@@ -255,24 +261,11 @@ class _CropScreenState extends State<CropScreen> {
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// FIX : On calcule d'abord la taille de la photo affichée avant de définir la taille du cadre vert !
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0;
final viewportAspect = _viewportSize.width / _viewportSize.height;
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
// visée = toute la zone visible → aucun bord noir autour du cadre.
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum),
// au lieu des 85% précédents qui rognaient inutilement les bords.
_cropSize = math.min(displayWidth, displayHeight);
if (_scale == 1.0 && _offset == Offset.zero) {
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
_initializeImagePosition();
}
@@ -287,12 +280,12 @@ class _CropScreenState extends State<CropScreen> {
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale)
..scaleByDouble(_scale, _scale, _scale, 1.0)
..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center,
child: Image.file(
File(_workingImagePath),
fit: BoxFit.contain,
File(widget.imagePath),
fit: BoxFit.cover,
width: _viewportSize.width,
height: _viewportSize.height,
),
@@ -309,14 +302,14 @@ class _CropScreenState extends State<CropScreen> {
child: Container(
width: double.infinity,
height: 1.5,
color: const Color(0xFF00FF00).withOpacity(0.6),
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withOpacity(0.6),
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
],
@@ -363,23 +356,124 @@ class _CropScreenState extends State<CropScreen> {
void _initializeImagePosition() {
if (_imageSize == null) return;
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
_scale = widget.initialScale ?? 1.0;
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
// 1. Calcul strict de la taille de l'image affichée en BoxFit.contain
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
if (widget.initialOffset != null) {
_offset = widget.initialOffset!;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
}
}
// On repart TOUJOURS de l'image entière, centrée, sans zoom :
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours.
_scale = 1.0;
_offset = Offset.zero;
// 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) {
@@ -399,24 +493,26 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants
final savedScale = _scale;
final savedOffset = _offset;
_scale = 1.0;
_offset = Offset.zero;
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
final double displayPerSourcePx = math.max(
_viewportSize.width / _imageSize!.width,
_viewportSize.height / _imageSize!.height,
);
final cropRect = _calculateCropRect();
// On restaure pour l'affichage (au cas où on revient en arrière)
_scale = savedScale;
_offset = savedOffset;
// Le crop est TOUJOURS calculé sur l'image originale, jamais sur un
// résultat déjà recadré.
final croppedImagePath = await _cropService.cropToSquare(
_workingImagePath,
cropRect,
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
// sont remplis en noir → la position choisie est respectée à l'identique.
final croppedImagePath = await _cropService.cropViewport(
sourcePath: widget.imagePath,
offsetDx: _offset.dx,
offsetDy: _offset.dy,
displayPerSourcePx: displayPerSourcePx,
cropSizeDisplay: _cropSize,
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
// il n'agrandit pas le rendu de sortie.
zoomScale: _scale,
rotationDegrees: _rotation,
);
@@ -430,13 +526,16 @@ class _CropScreenState extends State<CropScreen> {
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: croppedImagePath,
// On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir
// revenir au centrage sans jamais re-recadrer le résultat.
originalImagePath: _workingImagePath,
cropRotation: _rotation,
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
originalImagePath: widget.imagePath,
targetType: widget.targetType,
initialCenterX: targetCenterX,
initialCenterY: targetCenterY,
cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
),
),
);
@@ -450,43 +549,4 @@ class _CropScreenState extends State<CropScreen> {
}
}
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.clamp(0.0, 1.0),
y: relCropTop.clamp(0.0, 1.0),
width: relCropWidth.clamp(0.0, 1.0),
height: relCropHeight.clamp(0.0, 1.0),
);
}
}
+210 -29
View File
@@ -19,6 +19,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon;
List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0;
int _sessionCount = 0;
bool _isLoading = true;
@override
@@ -32,10 +33,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
final repository = context.read<SessionRepository>();
final history = await repository.getMaintenanceHistory(_weapon.id);
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
if (mounted) {
setState(() {
_maintenance = history;
_totalRounds = rounds;
_sessionCount = sessions;
_isLoading = false;
});
}
@@ -49,7 +52,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditWeaponDialog(context),
onPressed: _showEditWeaponDialog,
),
],
),
@@ -71,7 +74,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddMaintenanceDialog(context),
onPressed: _showAddMaintenanceDialog,
label: const Text('Entretien'),
icon: const Icon(Icons.build),
),
@@ -93,7 +96,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
Expanded(
child: _buildStatCard(
'Sessions',
'N/A',
_sessionCount.toString(),
Icons.history,
Colors.orange,
),
@@ -149,7 +152,24 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
IconButton(
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
),
onPressed: _showEditAccessoriesDialog,
),
],
),
const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
@@ -203,7 +223,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
title: Text(entry.type.displayName),
subtitle: Text(entry.description),
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
onLongPress: () => _confirmDeleteMaintenance(context, entry),
onLongPress: () => _confirmDeleteMaintenance(entry),
),
);
},
@@ -232,7 +252,36 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
}
}
void _showEditWeaponDialog(BuildContext context) async {
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
// (ex: Modérateur) se retrouve caché sous le clavier.
Widget _autoScrollOnFocus(Widget child) {
return Builder(
builder: (context) => Focus(
canRequestFocus: false,
skipTraversal: true,
onFocusChange: (hasFocus) {
if (!hasFocus) return;
// On attend que le clavier ait fini de redimensionner la vue.
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
});
},
child: child,
),
);
}
void _showEditWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber);
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
@@ -253,41 +302,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
_autoScrollOnFocus(TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
),
TextField(
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
),
)),
DropdownButtonFormField<WeaponType>(
value: selectedType,
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
_autoScrollOnFocus(TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'),
),
)),
const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
TextField(
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
),
TextField(
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
),
TextField(
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
),
)),
const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
Row(
_autoScrollOnFocus(Row(
children: [
Expanded(
child: TextField(
@@ -305,12 +354,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
),
),
],
),
TextField(
)),
_autoScrollOnFocus(TextField(
controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2,
),
)),
],
),
),
@@ -336,17 +385,125 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
customName: customNameController.text.isEmpty ? null : customNameController.text,
);
final repository = context.read<SessionRepository>();
await repository.updateWeapon(updatedWeapon);
if (!mounted) return;
setState(() {
_weapon = updatedWeapon;
});
}
}
void _showAddMaintenanceDialog(BuildContext context) async {
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
// pouvoir comparer les scores selon la configuration utilisée.
void _showEditAccessoriesDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier les accessoires'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
'pour pouvoir comparer les scores selon la configuration.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(
labelText: 'Surnom de la configuration',
hintText: 'ex: Echelon + Holosun',
),
)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
],
),
);
if (result != true || !mounted) return;
final newOptic = opticController.text.isEmpty ? null : opticController.text;
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
// Rien à faire si aucun accessoire n'a changé.
final unchanged = newOptic == _weapon.optic &&
newSilencer == _weapon.silencer &&
newTrigger == _weapon.trigger;
if (unchanged) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
);
return;
}
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
if (newCustomName == null) {
final accessories = [newOptic, newSilencer, newTrigger]
.where((a) => a != null && a.isNotEmpty)
.join(' / ');
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
}
final newWeapon = await repository.addWeapon(
name: _weapon.name,
type: _weapon.type,
caliber: _weapon.caliber,
magazineCount: _weapon.magazineCount,
magazineCapacity: _weapon.magazineCapacity,
notes: _weapon.notes,
optic: newOptic,
silencer: newSilencer,
trigger: newTrigger,
customName: newCustomName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
);
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
);
}
void _showAddMaintenanceDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning;
DateTime selectedDate = DateTime.now();
final result = await showDialog<bool>(
context: context,
@@ -357,7 +514,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<MaintenanceType>(
value: selectedType,
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
@@ -367,6 +524,27 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
maxLines: 2,
),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() => selectedDate = picked);
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date',
suffixIcon: Icon(Icons.calendar_today, size: 18),
),
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
),
),
],
),
actions: [
@@ -378,18 +556,21 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
);
if (result == true && descController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addMaintenanceEntry(
weaponId: _weapon.id,
type: selectedType,
description: descController.text,
roundsSinceLast: _totalRounds,
date: selectedDate,
);
if (!mounted) return;
_loadData();
}
}
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async {
void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -406,8 +587,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
);
if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteMaintenanceEntry(entry.id);
if (!mounted) return;
_loadData();
}
}
+113 -27
View File
@@ -7,7 +7,12 @@ import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart';
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
State<WeaponListScreen> createState() => _WeaponListScreenState();
@@ -23,6 +28,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
_loadWeapons();
}
@override
void didUpdateWidget(WeaponListScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshTick != widget.refreshTick) {
_loadWeapons();
}
}
Future<void> _loadWeapons() async {
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
@@ -46,7 +59,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
? _buildEmptyState()
: _buildWeaponList(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddWeaponDialog(context),
onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add),
),
);
@@ -62,7 +75,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
const Text('Aucune arme enregistrée'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => _showAddWeaponDialog(context),
onPressed: _showAddWeaponDialog,
child: const Text('Ajouter ma première arme'),
),
],
@@ -76,26 +89,11 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
itemCount: _weapons.length,
itemBuilder: (context, index) {
final weapon = _weapons[index];
final accessories = _accessoryChips(weapon);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${weapon.type.displayName}${weapon.caliber}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
await Navigator.push(
context,
@@ -103,14 +101,100 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
);
_loadWeapons(); // Reload in case it was edited or maintenance was added
},
onLongPress: () => _confirmDelete(context, weapon),
onLongPress: () => _confirmDelete(weapon),
child: Padding(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text(
'${weapon.type.displayName}${weapon.caliber}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
],
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
),
);
},
);
}
void _showAddWeaponDialog(BuildContext context) async {
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!));
}
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
items.add((Icons.volume_off, weapon.silencer!));
}
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
items.add((Icons.touch_app, weapon.trigger!));
}
return items.map((item) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
const SizedBox(width: 4),
Text(item.$2, style: const TextStyle(fontSize: 12)),
],
),
);
}).toList();
}
void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController();
final caliberController = TextEditingController();
final magCountController = TextEditingController(text: '2');
@@ -131,7 +215,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
),
DropdownButtonFormField<WeaponType>(
value: selectedType,
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
@@ -171,7 +255,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
);
if (result == true && nameController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addWeapon(
name: nameController.text,
type: selectedType,
@@ -179,11 +262,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
);
if (!mounted) return;
_loadWeapons();
}
}
void _confirmDelete(BuildContext context, Weapon weapon) async {
void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -200,8 +286,8 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
);
if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteWeapon(weapon.id);
if (!mounted) return;
_loadWeapons();
}
}
+45 -9
View File
@@ -11,7 +11,13 @@ import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
/// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
/// tant qu'on ne tire pas manuellement pour rafraîchir).
final int refreshTick;
const HistoryScreen({super.key, this.refreshTick = 0});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
@@ -31,6 +37,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
_loadSessions();
}
@override
void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
if (oldWidget.refreshTick != widget.refreshTick) {
_loadSessions();
}
}
Future<void> _loadSessions() async {
if (!mounted) return;
setState(() => _isLoading = true);
@@ -43,6 +59,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() {
_sessions = sessions;
// --- FILTRAGE PAR TYPE DE CIBLE ---
// Une session est retenue si au moins une de ses cibles est du type choisi.
if (_filterType != null) {
_sessions = _sessions
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
.toList();
}
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) {
_sessions = _sessions.where((s) {
@@ -103,7 +127,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogBackgroundColor: Colors.white,
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
@@ -125,17 +149,29 @@ class _HistoryScreenState extends State<HistoryScreen> {
appBar: AppBar(
title: const Text('Historique'),
actions: [
PopupMenuButton<TargetType?>(
icon: const Icon(Icons.filter_list),
onSelected: (type) {
setState(() => _filterType = type);
// NOTE : on passe par un String sentinelle ('all') car un
// PopupMenuItem avec value null ne déclenche jamais onSelected
// (Flutter l'interprète comme une annulation du menu).
PopupMenuButton<String>(
icon: Icon(
Icons.filter_list,
// Icône colorée quand un filtre est actif, pour le rendre visible.
color: _filterType != null ? AppTheme.primaryColor : null,
),
onSelected: (value) {
setState(() {
_filterType =
value == 'all' ? null : TargetType.fromString(value);
});
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(value: null, child: Text('Tous')),
const PopupMenuItem(value: 'all', child: Text('Tous')),
...TargetType.values.map(
(type) =>
PopupMenuItem(value: type, child: Text(type.displayName)),
(type) => PopupMenuItem(
value: type.name,
child: Text(type.displayName),
),
),
],
),
+49 -2
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../main_navigation_holder.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/repositories/session_repository.dart';
@@ -14,7 +15,12 @@ import '../session/session_provider.dart';
import 'widgets/stats_card.dart';
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
State<HomeScreen> createState() => _HomeScreenState();
@@ -25,12 +31,51 @@ class _HomeScreenState extends State<HomeScreen> {
List<Session> _recentSessions = [];
bool _isLoading = true;
SessionProvider? _sessionProvider;
bool _wasSessionActive = false;
@override
void initState() {
super.initState();
_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 {
final repository = context.read<SessionRepository>();
final stats = await repository.getStatistics();
@@ -199,6 +244,8 @@ class _HomeScreenState extends State<HomeScreen> {
backgroundColor: AppTheme.successColor,
),
);
// Fin de session : on bascule sur les statistiques.
openMainTab(mainTabStats);
},
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
),
@@ -308,7 +355,7 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded(
child: StatsCard(
icon: Icons.emoji_events,
title: 'Meilleur',
title: 'Meilleur score',
value: '${_stats!['bestScore']}',
color: AppTheme.successColor,
),
@@ -1,7 +1,5 @@
import 'package:flutter/foundation.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/weapon.dart';
class SessionProvider extends ChangeNotifier {
DateTime? _sessionDate;
@@ -168,7 +168,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
Column(
children: [
DropdownButtonFormField<Weapon>(
value: _selectedWeapon,
initialValue: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield),
+390 -32
View File
@@ -22,7 +22,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
String? _identityPhrase;
int? _photoCount;
bool _isLoadingStats = false;
bool _isCheckingStatus = false;
bool _isUploadEnabled = false;
bool _isBanned = false;
String? _banReason;
String _serverUrl = 'http://localhost:3000';
@override
void initState() {
@@ -32,11 +36,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadIdentity() async {
final phrase = await _walletService.getIdentityPhrase();
final serverUrl = await _walletService.getServerBaseUrl();
final isBanned = await _walletService.isBanned();
final banReason = await _walletService.getBanReason();
final isEnabled = await _walletService.isUploadEnabled();
if (mounted) {
setState(() {
_identityPhrase = phrase;
_isUploadEnabled = isEnabled;
_serverUrl = serverUrl;
_isBanned = isBanned;
_banReason = banReason;
_isUploadEnabled = isEnabled && !isBanned;
});
_fetchStats(phrase);
}
@@ -47,19 +57,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
try {
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
final baseUrl = await _walletService.getServerBaseUrl();
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost
final baseUrl = Theme.of(context).platform == TargetPlatform.android
? 'http://10.0.2.2:3000'
: 'http://localhost:3000';
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash'));
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
const Duration(seconds: 4),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (mounted) {
setState(() {
_photoCount = data['stats']['photo_count'] ?? 0;
_serverUrl = baseUrl;
_isLoadingStats = false;
});
}
@@ -67,11 +76,77 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) setState(() => _isLoadingStats = false);
}
} catch (e) {
print('Erreur lors du chargement des statistiques: $e');
debugPrint('Erreur lors du chargement des statistiques: $e');
if (mounted) setState(() => _isLoadingStats = false);
}
}
/// Action manuelle de l'utilisateur pour vérifier et synchroniser son statut auprès du serveur
Future<void> _refreshAccountStatus() async {
setState(() => _isCheckingStatus = true);
try {
final isBanned = await _walletService.syncBanStatus();
final banReason = await _walletService.getBanReason();
final isEnabled = await _walletService.isUploadEnabled();
if (_identityPhrase != null) {
await _fetchStats(_identityPhrase!);
}
if (!mounted) return;
setState(() {
_isBanned = isBanned;
_banReason = banReason;
_isUploadEnabled = isEnabled && !isBanned;
_isCheckingStatus = false;
});
if (isBanned) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.block, color: Colors.white),
const SizedBox(width: 10),
Expanded(
child: Text('Compte toujours suspendu : ${_banReason ?? "Non-respect des règles"}'),
),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 5),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
Icon(Icons.check_circle, color: Colors.white),
SizedBox(width: 10),
Expanded(
child: Text('Statut actualisé : votre compte est actif et autorisé !'),
),
],
),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 4),
),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isCheckingStatus = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Impossible de joindre le serveur : $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
void _copyToClipboard() {
if (_identityPhrase != null) {
Clipboard.setData(ClipboardData(text: _identityPhrase!));
@@ -85,8 +160,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _showThemeDialog() {
final themeProvider = context.read<ThemeProvider>();
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -184,8 +257,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _showOptInDisclaimer(bool value) {
if (_isBanned) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.block, color: AppTheme.errorColor),
SizedBox(width: 10),
Expanded(child: Text('Programme IA Suspendu')),
],
),
content: Text(
'Votre participation au programme d\'entraînement a été suspendue par la modération pour le motif suivant :\n\n'
'« ${_banReason ?? "Envois non conformes ou inappropriés"} »\n\n'
'L\'envoi de photos pour l\'entraînement est définitivement désactivé sur cet appareil.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Compris'),
),
],
),
);
return;
}
if (!value) {
// Si on désactive, pas besoin de disclaimer, on le fait direct.
_walletService.setUploadEnabled(false);
setState(() {
_isUploadEnabled = false;
@@ -198,28 +297,69 @@ class _SettingsScreenState extends State<SettingsScreen> {
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
content: const Column(
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
SizedBox(height: 16),
Text(
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles à notre serveur sécurisé.',
const Center(
child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
),
const SizedBox(height: 16),
const Text(
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles au serveur d\'entraînement IA.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 12),
Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique incassable.',
const SizedBox(height: 14),
const Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.',
style: TextStyle(fontSize: 13),
),
SizedBox(height: 12),
Text(
'🎁 Avantages Futurs : Votre contribution (Hash de Wallet) sera comptabilisée. Lors de la sortie publique de notre IA, les participants actifs recevront des fonctionnalités premium ou des badges exclusifs en récompense !',
const SizedBox(height: 10),
const Text(
'🎁 Récompenses : Vos contributions sont enregistrées pour vous donner accès à des fonctionnalités exclusives.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.4)),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 20),
SizedBox(width: 8),
Text(
'Règles strictes & Bannissement',
style: TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
SizedBox(height: 6),
Text(
'Vous vous engagez à n\'envoyer que de réelles cibles de tir conformes. '
'Tout envoi de photos non conformes, fausses cibles, images floues ou contenu inapproprié '
'entraînera le bannissement immédiat et définitif de votre compte. '
'L\'application perdra définitivement la possibilité d\'envoyer des photos.',
style: TextStyle(fontSize: 12, height: 1.4),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@@ -240,7 +380,143 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
},
child: const Text('J\'accepte', style: TextStyle(color: Colors.white)),
child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)),
),
],
),
);
}
void _showEditServerUrlDialog() {
final urlController = TextEditingController(text: _serverUrl);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Adresse du Serveur IA'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Indiquez l\'adresse IP ou l\'URL du serveur backend IA (port 3000) :',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
TextField(
controller: urlController,
decoration: const InputDecoration(
hintText: 'Ex: http://192.168.1.50:3000',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
const Text(
'💡 Sur émulateur : http://10.0.2.2:3000\n💡 Sur smartphone réel : IP locale de votre PC (ex: http://192.168.1.X:3000)',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
onPressed: () async {
final newUrl = urlController.text.trim();
if (newUrl.isNotEmpty) {
await _walletService.setServerBaseUrl(newUrl);
setState(() {
_serverUrl = newUrl;
});
if (_identityPhrase != null) {
_fetchStats(_identityPhrase!);
}
}
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Adresse du serveur IA mise à jour'),
backgroundColor: AppTheme.successColor,
),
);
},
child: const Text('Enregistrer', style: TextStyle(color: Colors.white)),
),
],
),
);
}
// Rapport de bug autonome : on collecte une description + les infos
// techniques, et on copie un rapport prêt à coller dans un email de support.
void _showReportBugDialog() {
final descController = TextEditingController();
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
const appVersion = '1.0.0';
final platform = Theme.of(context).platform.name;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler un bug'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Décrivez le problème : ce que vous faisiez, ce qui était attendu et ce qui s\'est passé.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
TextField(
controller: descController,
maxLines: 5,
decoration: const InputDecoration(
hintText: 'Ex: l\'application se ferme quand j\'ouvre une session...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
'Infos techniques jointes : version $appVersion$platform',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier le rapport'),
onPressed: () {
final desc = descController.text.trim();
final report = StringBuffer()
..writeln('--- Rapport de bug ---')
..writeln('Version : $appVersion')
..writeln('Plateforme : $platform')
..writeln('Date : ${DateTime.now().toIso8601String()}')
..writeln('')
..writeln('Description :')
..writeln(desc.isEmpty ? '(non renseignée)' : desc);
Clipboard.setData(ClipboardData(text: report.toString()));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Rapport copié — collez-le dans un email à $supportEmail'),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 5),
),
);
},
),
],
),
@@ -292,7 +568,77 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 24),
_buildSectionHeader('Configuration Serveur IA'),
_buildSectionHeader('Programme d\'Entraînement IA'),
if (_isBanned) ...[
Card(
elevation: 0,
color: AppTheme.errorColor.withValues(alpha: 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.block, color: AppTheme.errorColor, size: 22),
SizedBox(width: 10),
Expanded(
child: Text(
'Participation au programme IA suspendue',
style: TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Motif : ${_banReason ?? "Envois d'images non appropriées ou fausses cibles."}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
const Text(
'Suite à des envois non conformes, la possibilité de participer au programme d\'entraînement et d\'envoyer des photos a été révoquée pour cet identifiant.',
style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.3),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: _isCheckingStatus
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.refresh, size: 18),
label: Text(
_isCheckingStatus ? 'Vérification...' : 'ACTUALISER MON STATUT',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
),
),
],
),
),
),
] else ...[
Card(
elevation: 0,
color: Colors.transparent,
@@ -300,10 +646,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled,
activeColor: AppTheme.primaryColor,
activeThumbColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
@@ -316,12 +662,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
icon: Icons.cloud_outlined,
title: 'Adresse du Serveur IA',
subtitle: 'http://localhost:3000/api/upload',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Changement d\'adresse à venir')),
);
},
subtitle: _serverUrl,
onTap: _showEditServerUrlDialog,
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.sync,
title: 'Actualiser mon statut',
subtitle: _isCheckingStatus ? 'Vérification en cours...' : 'Vérifier l\'état du compte auprès du serveur',
onTap: _isCheckingStatus ? () {} : _refreshAccountStatus,
),
if (_isUploadEnabled)
_buildSettingsTile(
@@ -337,6 +687,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
},
),
],
const SizedBox(height: 24),
_buildSectionHeader('À propos'),
@@ -347,6 +698,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: '1.0.0',
onTap: () {},
),
_buildSettingsTile(
context: context,
icon: Icons.bug_report_outlined,
title: 'Signaler un bug',
subtitle: 'Aidez-nous à corriger les problèmes',
onTap: _showReportBugDialog,
),
_buildSettingsTile(
context: context,
icon: Icons.privacy_tip_outlined,
+537 -12
View File
@@ -1,16 +1,27 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
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/repositories/session_repository.dart';
import '../../services/backup_service.dart';
import '../../services/statistics_service.dart';
class StatisticsScreen extends StatefulWidget {
final Session? singleSession;
const StatisticsScreen({super.key, this.singleSession});
/// Incrémenté par la navigation à chaque fois que l'onglet Stats est ouvert,
/// pour forcer un rechargement des données (l'écran est gardé vivant par un
/// IndexedStack, sinon les filtres arme/distance resteraient périmés).
final int refreshTick;
const StatisticsScreen({super.key, this.singleSession, this.refreshTick = 0});
@override
State<StatisticsScreen> createState() => _StatisticsScreenState();
@@ -18,7 +29,7 @@ class StatisticsScreen extends StatefulWidget {
class _StatisticsScreenState extends State<StatisticsScreen> {
final StatisticsService _statisticsService = StatisticsService();
StatsPeriod _selectedPeriod = StatsPeriod.all;
final StatsPeriod _selectedPeriod = StatsPeriod.all;
SessionStatistics? _statistics;
bool _isLoading = true;
List<Session> _allSessions = [];
@@ -29,12 +40,34 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> _availableWeapons = ['Toutes'];
List<String> _availableDistances = ['Toutes'];
// --- Comparateur de sessions ---
// Quand 2 sessions sont sélectionnées, l'écran passe en mode comparaison :
// un switch permet d'alterner l'affichage des stats entre la session A et B.
Session? _compareA;
Session? _compareB;
bool _showingB = false; // false = on affiche A, true = on affiche B
bool get _compareMode => _compareA != null && _compareB != null;
// --- Sauvegarde (export/import JSON) ---
bool _isBackupBusy = false;
final GlobalKey _exportButtonKey = GlobalKey();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
}
@override
void didUpdateWidget(StatisticsScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour rafraîchir les listes
// de filtres (armes/distances) et les stats avec les nouvelles sessions.
if (oldWidget.refreshTick != widget.refreshTick) {
_loadStatistics();
}
}
Future<void> _loadStatistics() async {
if (!mounted) return;
setState(() => _isLoading = true);
@@ -79,6 +112,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
}
void _calculateStats() {
// Mode comparaison : on calcule les stats sur la seule session active
// (A ou B selon le switch), sans filtre de période.
if (_compareMode) {
final active = _showingB ? _compareB! : _compareA!;
_statistics = _statisticsService.calculateStatistics(
[active],
period: StatsPeriod.all,
);
return;
}
// Filtrer les sessions avant calcul
final filteredSessions = _allSessions.where((s) {
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
@@ -92,6 +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() {
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
// Sort sessions by date and take last 10
@@ -123,11 +241,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
: Column(
children: [
// HEADER COLLANT : filtres + comparateur toujours visibles
Material(
elevation: 2,
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
children: [
// 1. FILTRES (Arme et Distance)
@@ -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)
Row(
children: [
@@ -191,18 +326,46 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
'Score',
'${_statistics?.totalScore ?? 0}',
_getScoreHistory(),
explanations: const [
MetricExplanation(
'Score',
'Total des points marqués sur la période sélectionnée.',
),
],
),
const SizedBox(height: 20),
_buildChartSection(
'Précision',
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
_getPrecisionHistory(),
explanations: const [
MetricExplanation(
'Précision',
'Proximité moyenne de vos impacts par rapport au centre '
'de la cible (calculée sur les distances).',
),
MetricExplanation(
'À ne pas confondre',
'C\'est différent de la « Réussite » affichée dans une '
'session, qui se base sur les points marqués. Les deux '
'mesurent des choses distinctes, leurs pourcentages '
'ne sont donc pas identiques.',
),
],
),
const SizedBox(height: 20),
_buildChartSection(
'Groupement moyen',
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%',
'Étalement moyen',
'${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
explanations: const [
MetricExplanation(
'Étalement moyen',
'Étalement moyen de vos groupements : distance entre les '
'impacts les plus éloignés, en % de la largeur de '
'l\'image. Plus c\'est bas, plus vos tirs sont serrés.',
),
],
),
const SizedBox(height: 25),
@@ -220,11 +383,107 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
_buildBiasWarning(),
],
const SizedBox(height: 25),
// 5. SAUVEGARDE : export/import de toutes les données
_buildBackupSection(),
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(
String title,
String value,
List<double> dataPoints,
) {
List<double> dataPoints, {
List<MetricExplanation>? explanations,
}) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
@@ -313,11 +573,19 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
if (explanations != null) ...[
const Spacer(),
MetricInfoButton(title: title, explanations: explanations),
],
],
),
const SizedBox(height: 10),
Row(
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 {
-10
View File
@@ -7,10 +7,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'app.dart';
import 'core/theme/theme_provider.dart';
import 'data/repositories/session_repository.dart';
import 'services/target_detection_service.dart';
import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart';
import 'services/image_processing_service.dart';
import 'features/session/session_provider.dart';
void main() async {
@@ -32,14 +30,6 @@ void main() async {
runApp(
MultiProvider(
providers: [
Provider<ImageProcessingService>(
create: (_) => ImageProcessingService(),
),
Provider<TargetDetectionService>(
create: (context) => TargetDetectionService(
imageProcessingService: context.read<ImageProcessingService>(),
),
),
Provider<ScoreCalculatorService>(
create: (_) => ScoreCalculatorService(),
),
+38 -9
View File
@@ -5,6 +5,23 @@ import 'features/statistics/statistics_screen.dart';
import 'features/garage/weapon_list_screen.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 {
const MainNavigationHolder({super.key});
@@ -15,25 +32,37 @@ class MainNavigationHolder extends StatefulWidget {
class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0;
final List<Widget> _screens = [
const HomeScreen(),
const HistoryScreen(),
const StatisticsScreen(),
const WeaponListScreen(),
];
// Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
// rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
// rafraîchissent pas seuls).
int _statsTick = 0;
int _historyTick = 0;
int _homeTick = 0;
int _garageTick = 0;
void _onItemTapped(int index) {
void selectTab(int index) {
if (!mounted) return;
setState(() {
_selectedIndex = index;
if (index == mainTabHome) _homeTick++;
if (index == mainTabHistory) _historyTick++;
if (index == mainTabStats) _statsTick++;
if (index == mainTabGarage) _garageTick++;
});
}
@override
Widget build(BuildContext context) {
final screens = [
HomeScreen(refreshTick: _homeTick),
HistoryScreen(refreshTick: _historyTick),
StatisticsScreen(refreshTick: _statsTick),
WeaponListScreen(refreshTick: _garageTick),
];
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _screens,
children: screens,
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
@@ -47,7 +76,7 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
),
child: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
onTap: selectTab,
type: BottomNavigationBarType.fixed,
backgroundColor: Theme.of(context).cardColor,
selectedItemColor: AppTheme.primaryColor,
+112 -34
View File
@@ -8,16 +8,62 @@ import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import 'wallet_identity_service.dart';
class AiExportService {
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
static String get _defaultApiUrl {
if (Platform.isAndroid) {
return 'http://10.0.2.2:3000/api/upload';
}
return 'http://localhost:3000/api/upload';
/// Résultat détaillé de l'exportation vers le serveur IA
class AiExportResult {
final bool isSuccess;
final String code;
final String message;
final String? reason;
final bool isBanned;
final Map<String, dynamic>? targetValidation;
AiExportResult({
required this.isSuccess,
required this.code,
required this.message,
this.reason,
this.isBanned = false,
this.targetValidation,
});
factory AiExportResult.success({
String? message,
Map<String, dynamic>? targetValidation,
}) {
return AiExportResult(
isSuccess: true,
code: 'UPLOAD_SUCCESS',
message: message ?? 'Export réussi vers le serveur IA !',
targetValidation: targetValidation,
);
}
factory AiExportResult.banned({
String? reason,
String? message,
}) {
return AiExportResult(
isSuccess: false,
code: 'WALLET_BANNED',
isBanned: true,
reason: reason,
message: message ?? 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
);
}
factory AiExportResult.error({
String? code,
required String message,
}) {
return AiExportResult(
isSuccess: false,
code: code ?? 'UPLOAD_ERROR',
message: message,
);
}
}
class AiExportService {
/// Extrait les informations de l'appareil
Future<Map<String, dynamic>> _getDeviceInfo() async {
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
@@ -45,7 +91,7 @@ class AiExportService {
}
/// Exporte l'image et les données de plotting vers le serveur
Future<bool> exportData({
Future<AiExportResult> exportData({
required String imagePath,
required String sessionId,
required TargetType targetType,
@@ -53,26 +99,28 @@ class AiExportService {
required double targetCenterY,
required double targetRadius,
required List<Shot> shots,
int distanceMeters = 25,
String weaponName = 'Unknown',
String? apiUrl,
}) async {
try {
final url = Uri.parse(apiUrl ?? _defaultApiUrl);
final walletService = WalletIdentityService();
final baseUrl = await walletService.getServerBaseUrl();
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
final url = Uri.parse(effectiveUrl);
final request = http.MultipartRequest('POST', url);
// 1. Prepare image
final file = File(imagePath);
if (!await file.exists()) {
throw Exception('Le fichier image n\'existe pas');
return AiExportResult.error(
code: 'FILE_NOT_FOUND',
message: 'Le fichier image cible est introuvable.',
);
}
// Read image metadata (approximate dimensions since decoding image can be heavy)
// On the frontend we usually have aspectRatio, here we use generic values if not available.
final deviceData = await _getDeviceInfo();
// We approximate the target corners from center and radius
// radius is relative (0 to 1). We need image width/height to get pixels.
// But we can just pass relative corners as well, or a normalized bounding box.
// Let's create normalized corners (0 to 1).
final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
@@ -96,7 +144,6 @@ class AiExportService {
}).toList();
// Get and hash the wallet identity
final walletService = WalletIdentityService();
final phrase = await walletService.getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
@@ -109,9 +156,8 @@ class AiExportService {
"device_info": deviceData,
"target_metadata": {
"type": targetType.name,
"distance_meters": 25, // Default/placeholder
"weapon": "Unknown", // Default/placeholder
// The backend could extract exact width/height from the image.
"distance_meters": distanceMeters,
"weapon": weaponName,
},
"plotting": {
"target_corners": corners,
@@ -119,29 +165,61 @@ class AiExportService {
}
};
// Add fields to request
request.fields['plotting'] = jsonEncode(plottingJson);
// Add file
request.files.add(
await http.MultipartFile.fromPath('photo', imagePath),
);
// Send request
final response = await request.send();
final streamedResponse = await request.send().timeout(
const Duration(seconds: 15),
onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
);
if (response.statusCode == 200) {
final responseData = await response.stream.bytesToString();
debugPrint('Export réussi: $responseData');
return true;
final responseBody = await streamedResponse.stream.bytesToString();
Map<String, dynamic> responseJson = {};
try {
responseJson = jsonDecode(responseBody);
} catch (_) {}
final statusCode = streamedResponse.statusCode;
if (statusCode == 200) {
debugPrint('Export réussi: $responseBody');
return AiExportResult.success(
message: responseJson['message'] ?? 'Photo et données exportées avec succès.',
targetValidation: responseJson['target_validation'] as Map<String, dynamic>?,
);
} else if (statusCode == 403 || responseJson['code'] == 'WALLET_BANNED') {
final reason = responseJson['reason'] ?? 'Non-respect des règles de contribution';
debugPrint('Export rejeté (banni): $reason');
// Persister le bannissement localement et couper l'envoi de photos
await walletService.setBanned(true, reason: reason);
return AiExportResult.banned(
reason: reason,
message: responseJson['error'] ?? 'Votre wallet a été suspendu par la modération.',
);
} else if (statusCode == 400) {
return AiExportResult.error(
code: responseJson['code'] ?? 'BAD_REQUEST',
message: responseJson['error'] ?? 'Requête d\'export invalide.',
);
} else {
final errorData = await response.stream.bytesToString();
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
return false;
return AiExportResult.error(
code: responseJson['code'] ?? 'SERVER_ERROR',
message: responseJson['error'] ?? 'Erreur serveur ($statusCode).',
);
}
} on SocketException {
return AiExportResult.error(
code: 'NETWORK_ERROR',
message: 'Impossible de joindre le serveur IA. Vérifiez l\'adresse IP ou votre connexion.',
);
} catch (e) {
debugPrint('Exception lors de l\'export: $e');
return false;
return AiExportResult.error(
code: 'UNKNOWN_ERROR',
message: 'Erreur lors de l\'export: $e',
);
}
}
}
+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
@@ -1,54 +0,0 @@
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
/// Résultat du scan de document.
class DocumentScanResult {
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
final String? imagePath;
/// true si un scan a bien été produit.
bool get success => imagePath != null;
const DocumentScanResult(this.imagePath);
}
/// Service qui lance le scanner de documents Google ML Kit.
///
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
///
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
class DocumentScannerService {
/// Lance le scanner et renvoie le chemin de l'image redressée.
///
/// [pageLimit] est fixé à 1 (une seule cible par scan).
/// On désactive l'import galerie ici car la galerie est gérée séparément.
///
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
/// rehaussement type photocopie) → détruit les couleurs de la cible.
/// - `base` ne fait QUE le recadrage + le redressement de perspective
/// (mise de face), en conservant l'image en couleurs réelles. C'est
/// indispensable pour pouvoir détecter ensuite les impacts.
Future<DocumentScanResult> scanTarget() async {
final options = DocumentScannerOptions(
mode: ScannerMode.base, // redressement seul, sans filtre couleur
pageLimit: 1,
isGalleryImport: false,
);
final scanner = DocumentScanner(options: options);
try {
final DocumentScanningResult result = await scanner.scanDocument();
final images = result.images;
if (images.isEmpty) {
return const DocumentScanResult(null);
}
return DocumentScanResult(images.first);
} finally {
// Libère les ressources natives du scanner.
await scanner.close();
}
}
}
+141 -30
View File
@@ -55,37 +55,7 @@ void _cropIsolateEntry(_CropParams params) {
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
}
// ── OPTIMISATION MAJEURE : pré-réduction avant rotation ───────────────────
// La rotation (copyRotate) est l'opération la plus lourde du package `image`
// et son coût est proportionnel au nombre de pixels. Comme la sortie finale
// ne fait que `outputSize` px, on n'a aucun intérêt à faire pivoter une image
// de plusieurs dizaines de mégapixels.
//
// On réduit donc l'image source à une "taille de travail" juste suffisante
// AVANT toute rotation/crop. Le crop garde ~85% du cadre, donc on laisse une
// marge : workMax = outputSize / 0.7 couvre largement le besoin sans perte
// visible. Les coordonnées de crop étant relatives (0..1), elles restent
// valables quelle que soit l'échelle.
final int workMax = (params.outputSize / 0.7).round();
final int longestSide = math.max(originalImage.width, originalImage.height);
if (longestSide > workMax) {
if (originalImage.width >= originalImage.height) {
originalImage = img.copyResize(
originalImage,
width: workMax,
interpolation: img.Interpolation.linear,
);
} else {
originalImage = img.copyResize(
originalImage,
height: workMax,
interpolation: img.Interpolation.linear,
);
}
}
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
// (désormais appliquée sur une image déjà réduite → beaucoup plus rapide)
if (params.rotationDegrees != 0.0) {
originalImage = img.copyRotate(
originalImage,
@@ -128,11 +98,152 @@ void _cropIsolateEntry(_CropParams params) {
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
}
// AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir)
class _ViewportCropParams {
final String sourcePath;
final double offsetDx;
final double offsetDy;
final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain)
final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran
final double zoomScale; // zoom utilisateur : sert UNIQUEMENT au pointage
final double rotationDegrees;
final int outputSize;
final String outputPath;
_ViewportCropParams({
required this.sourcePath,
required this.offsetDx,
required this.offsetDy,
required this.displayPerSourcePx,
required this.cropSizeDisplay,
required this.zoomScale,
required this.rotationDegrees,
required this.outputSize,
required this.outputPath,
});
}
// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation
// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute
// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur
// l'a placée, même collée à un bord (aucun recentrage forcé).
void _viewportCropIsolateEntry(_ViewportCropParams p) {
final bytes = File(p.sourcePath).readAsBytesSync();
final img.Image? src = img.decodeImage(bytes);
if (src == null) {
throw Exception('Impossible de décoder l\'image: ${p.sourcePath}');
}
// Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels.
img.Image rotated = src;
if (p.rotationDegrees != 0.0) {
rotated = img.copyRotate(
src,
angle: p.rotationDegrees,
interpolation: img.Interpolation.linear,
);
}
final double f = p.displayPerSourcePx;
// Côté de la fenêtre de visée en pixels source. On utilise f SEUL (pas le
// zoom) → le champ de vision capturé est toujours celui de l'image non
// zoomée : le zoom n'est donc PAS pris en compte dans le rendu de sortie.
final int side = math.max(1, (p.cropSizeDisplay / f).round());
// Centre de la fenêtre de visée, en pixels de l'image (rotated).
// La translation écran s'applique APRÈS le zoom, donc un déplacement écran
// `offset` vaut `offset / (f * zoom)` pixels source. On inclut donc le zoom
// ICI (uniquement pour le pointage) afin que la croix vise le même point
// quel que soit le niveau de zoom.
final double fz = f * p.zoomScale;
final double cropCenterX = rotated.width / 2 - p.offsetDx / fz;
final double cropCenterY = rotated.height / 2 - p.offsetDy / fz;
final int srcX = (cropCenterX - side / 2).round();
final int srcY = (cropCenterY - side / 2).round();
// Toile carrée NOIRE opaque (numChannels 3 → pixels initialisés à 0 = noir).
final out = img.Image(width: side, height: side, numChannels: 3);
// Intersection de la fenêtre de visée avec l'image réelle. Tout ce qui
// déborde reste noir (padding) → aucun recentrage forcé.
final int vx0 = math.max(0, srcX);
final int vy0 = math.max(0, srcY);
final int vx1 = math.min(rotated.width, srcX + side);
final int vy1 = math.min(rotated.height, srcY + side);
final int vw = vx1 - vx0;
final int vh = vy1 - vy0;
if (vw > 0 && vh > 0) {
final region = img.copyCrop(rotated, x: vx0, y: vy0, width: vw, height: vh);
// dstW/dstH EXPLICITES = taille de la région → AUCUN redimensionnement
// (sinon compositeImage étire la source pour remplir la destination).
img.compositeImage(
out,
region,
dstX: vx0 - srcX,
dstY: vy0 - srcY,
dstW: vw,
dstH: vh,
);
}
img.Image result = out;
if (side != p.outputSize) {
result = img.copyResize(
out,
width: p.outputSize,
height: p.outputSize,
interpolation: img.Interpolation.linear,
);
}
File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85));
}
class ImageCropService {
final Uuid _uuid = const Uuid();
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, {
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 {
/// Detect the main target (center and radius) from an image file
///
/// IMPORTANT : les Mat OpenCV sont de la mémoire NATIVE, invisible pour le
/// garbage collector Dart. Cette méthode est appelée en boucle (~1 s)
/// pendant l'aperçu caméra : sans dispose() explicite dans le finally, la
/// mémoire native grimpe en continu tant que l'utilisateur vise.
Future<TargetDetectionResult> detectTarget(String imagePath) async {
cv.Mat? img;
cv.Mat? gray;
cv.Mat? blurred;
cv.Mat? circles;
cv.Mat? looseCircles;
try {
// Read image
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) {
return TargetDetectionResult.failure();
}
// Convert to grayscale
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply Gaussian blur to reduce noise
final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
// Detect circles using Hough Transform
// Parameters need to be tuned for the specific target type
final circles = cv.HoughCircles(
// Detect circles using Hough Transform.
// HoughCircles returns a Mat of shape (1, N) of Vec3f (x, y, r).
circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1, // dp
@@ -55,26 +66,9 @@ class OpenCVTargetService {
maxRadius: img.cols ~/ 2,
);
// HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles.
// In opencv_dart, we cannot iterate easily.
// However, we can access data via pointer if needed, or check if Vec3f is supported.
// Given the user report, `at<Vec3f>` likely failed compilation or runtime.
// Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...).
// Or use `at<double>` carefully.
// Better yet: try to use `circles.data` if available, but it returns a Pointer.
// Let's stick to `at` but use `double` and manual offset if Vec3f fails.
// actually, let's try to trust `at<double>` for flattened access OR `at<Vec3f>`.
// NOTE: `at<Vec3f>` was reported as "method at not defined for VecPoint2f" earlier, NOT for Mat.
// The user error was for `VecPoint2f`. `Mat` definitely has `at`.
// BUT `VecPoint2f` is a List-like structure in Dart wrapper.
// usage of `at` on `VecPoint2f` was the error.
// Here `circles` IS A MAT. So `at` IS defined.
// However, to be safe and robust, and to implement clustering...
if (circles.isEmpty) {
// Try with different parameters if first attempt fails (more lenient)
final looseCircles = cv.HoughCircles(
looseCircles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
@@ -93,8 +87,15 @@ class OpenCVTargetService {
return _findBestConcentricCircles(circles, img.cols, img.rows);
} catch (e) {
// print('Error detecting target with OpenCV: $e');
return TargetDetectionResult.failure();
} finally {
// _findBestConcentricCircles a déjà extrait les données dans des listes
// Dart avant qu'on arrive ici : libérer les Mat est donc toujours sûr.
img?.dispose();
gray?.dispose();
blurred?.dispose();
circles?.dispose();
looseCircles?.dispose();
}
}
-1
View File
@@ -119,7 +119,6 @@ class ParallelismService {
// Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude;
final double ny = gy / magnitude;
final double nz = gz / magnitude;
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
-461
View File
@@ -1,461 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../data/models/target_type.dart';
import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart';
export 'image_processing_service.dart'
show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact;
// ============================================================================
// STRUCT ET FONCTION GLOBALE POUR LE PARALLÉLISME (THREAD SECONDAIRE)
// ============================================================================
/// Conteneur de données pour envoyer les paramètres à l'Isolate d'arrière-plan.
class DetectionPayload {
final String imagePath;
final TargetType targetType;
// On recrée les instances à l'intérieur du thread isolé car les objets ne se partagent pas entre threads.
DetectionPayload({
required this.imagePath,
required this.targetType,
});
}
/// FONCTION EXÉCUTÉE EN PARALLÈLE : Tourne sur un autre cœur du processeur.
/// L'interface graphique reste à 120 FPS et totalement fluide.
TargetDetectionResult runParallelTargetDetection(DetectionPayload payload) {
// 1. Initialisation locale des services dans le sous-thread
final imageProcessingService = ImageProcessingService();
try {
// 2. Détection de la cible principale (Calcul lourd)
final mainTarget = imageProcessingService.detectMainTarget(payload.imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
// 3. Détection des impacts (Calcul lourd)
final impacts = imageProcessingService.detectImpacts(payload.imagePath);
// 4. Calcul mathématique des scores relatifs
final detectedImpacts = impacts.map((impact) {
final score = payload.targetType == TargetType.concentric
? _staticCalculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _staticCalculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection parallèle: $e');
}
}
// Fonctions mathématiques pures nécessaires à l'Isolate (statiques)
int _staticCalculateConcentricScore(double impactX, double impactY, double centerX, double centerY, double targetRadius) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0;
}
int _staticCalculateSilhouetteScore(double impactX, double impactY, double centerX, double centerY) {
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
if (dx > 0.15) return 0;
if (dy < -0.25) return 5;
if (dy < 0.0) return 5;
if (dy < 0.15) return 4;
if (dy < 0.35) return 3;
return 0;
}
// ============================================================================
// FIN DU BLOC DE PARALLÉLISME
// ============================================================================
class TargetDetectionResult {
final double centerX; // Relative (0-1)
final double centerY; // Relative (0-1)
final double radius; // Relative (0-1)
final List<DetectedImpactResult> impacts;
final bool success;
final String? errorMessage;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
required this.impacts,
this.success = true,
this.errorMessage,
});
factory TargetDetectionResult.error(String message) {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
impacts: [],
success: false,
errorMessage: message,
);
}
}
class DetectedImpactResult {
final double x; // Relative (0-1)
final double y; // Relative (0-1)
final double radius; // Absolute pixels
final int suggestedScore;
DetectedImpactResult({
required this.x,
required this.y,
required this.radius,
required this.suggestedScore,
});
}
class TargetDetectionService {
final ImageProcessingService _imageProcessingService;
final OpenCVImpactDetectionService _opencvService;
TargetDetectionService({
ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file ASYNCHRONOUSLY in a separate Thread.
/// CORRECTION : Utilise désormais 'compute' pour basculer en arrière-plan immédiat.
Future<TargetDetectionResult> detectTargetAsync(String imagePath, TargetType targetType) async {
final payload = DetectionPayload(imagePath: imagePath, targetType: targetType);
// Déclenche l'exécution isolée en tâche de fond
return await compute(runParallelTargetDetection, payload);
}
/// Gardée pour rétrocompatibilité synchrone si nécessaire
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
try {
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
final impacts = _imageProcessingService.detectImpacts(imagePath);
final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection: $e');
}
}
int _calculateConcentricScore(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0;
}
int _calculateSilhouetteScore(
double impactX,
double impactY,
double centerX,
double centerY,
) {
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
if (dx > 0.15) return 0;
if (dy < -0.25) return 5;
if (dy < 0.0) return 5;
if (dy < 0.15) return 4;
if (dy < 0.35) return 3;
return 0;
}
List<DetectedImpactResult> detectImpactsOnly(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactDetectionSettings settings,
) {
try {
final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath,
settings,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
int _calculateConcentricScoreWithRings(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
int ringCount,
) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) {
return 10 - i;
}
}
return 0;
}
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references,
) {
return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
}
List<DetectedImpactResult> detectImpactsFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactCharacteristics characteristics, {
double tolerance = 2.0,
}) {
try {
final impacts = _imageProcessingService.detectImpactsFromReferences(
imagePath,
characteristics,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
List<DetectedImpactResult> detectImpactsWithOpenCV(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount, {
OpenCVDetectionSettings? settings,
}) {
try {
final impacts = _opencvService.detectImpacts(
imagePath,
settings: settings ?? const OpenCVDetectionSettings(),
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV: $e');
return [];
}
}
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
List<ReferenceImpact> references, {
double tolerance = 2.0,
}) {
try {
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
final impacts = _opencvService.detectFromReferences(
imagePath,
refPoints,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV depuis références: $e');
return [];
}
}
}
-259
View File
@@ -1,259 +0,0 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:opencv_dart/opencv_dart.dart' as cv;
/// Résultat d'une tentative de redressement de cible.
class RectifyResult {
/// Chemin du fichier image redressé (ou original si échec).
final String outputPath;
/// true si une cible a été détectée et redressée, false si on a renvoyé
/// l'image d'origine sans transformation.
final bool rectified;
/// Angle d'inclinaison estimé de la cible AVANT redressement, en degrés.
/// (0 = déjà de face). Utile pour informer l'utilisateur.
final double estimatedTiltDegrees;
/// Message de diagnostic (utile pour debug / affichage).
final String message;
const RectifyResult({
required this.outputPath,
required this.rectified,
required this.estimatedTiltDegrees,
required this.message,
});
}
/// Service qui redresse une cible RONDE (cercles concentriques) photographiée
/// de biais, en la ramenant parfaitement de face.
///
/// Principe :
/// 1. Détecter le plus grand contour ~circulaire de l'image.
/// 2. Ajuster une ELLIPSE sur ce contour (fitEllipse).
/// 3. Un cercle vu en perspective devient une ellipse : on calcule la
/// transformation de perspective qui remappe cette ellipse vers un
/// CERCLE parfait, et on l'applique à toute l'image.
/// 4. La cible apparaît alors de face.
///
/// L'image résultat est carrée et centrée sur la cible.
class TargetRectifyService {
/// Taille (en pixels) du côté de l'image carrée de sortie.
final int outputSize;
/// Marge autour de la cible dans l'image de sortie (1.0 = cible pile au bord,
/// 1.3 = 30 % de marge autour). Garde un peu de contexte.
final double marginFactor;
/// En dessous de cet écart d'axes (ratio petit/grand axe proche de 1),
/// la cible est considérée déjà de face → pas de warp inutile.
final double minTiltRatioToRectify;
TargetRectifyService({
this.outputSize = 1024,
this.marginFactor = 1.25,
this.minTiltRatioToRectify = 0.985,
});
/// Redresse l'image située à [inputPath]. Écrit le résultat dans
/// [outputPath] et renvoie un [RectifyResult].
///
/// Ne bloque jamais : en cas d'échec de détection, renvoie l'image
/// d'origine (rectified = false) pour ne pas perdre la photo du tireur.
Future<RectifyResult> rectify({
required String inputPath,
required String outputPath,
}) async {
cv.Mat? src;
cv.Mat? gray;
cv.Mat? blurred;
cv.Mat? edges;
try {
src = cv.imread(inputPath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Image illisible',
);
}
// ── 1. Prétraitement ────────────────────────────────────────────────
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
blurred = cv.gaussianBlur(gray, (5, 5), 2, sigmaY: 2);
edges = cv.canny(blurred, 60, 160);
// Dilatation légère pour fermer les contours brisés
final cv.Mat kernel = cv.getStructuringElement(
cv.MORPH_ELLIPSE,
(3, 3),
);
final cv.Mat dilated = cv.dilate(edges, kernel);
// ── 2. Recherche du meilleur contour elliptique ──────────────────────
final (contours, _) = cv.findContours(
dilated,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final double imgArea = (src.width * src.height).toDouble();
cv.RotatedRect? bestEllipse;
double bestScore = 0;
for (int i = 0; i < contours.length; i++) {
final c = contours[i];
if (c.length < 5) continue; // fitEllipse exige >= 5 points
final double area = cv.contourArea(c);
// On ignore les contours minuscules et ceux qui couvrent presque tout
if (area < imgArea * 0.03 || area > imgArea * 0.97) continue;
final cv.RotatedRect e = cv.fitEllipse(c);
final ep = e.points; // 4 sommets
if (ep.length < 4) continue;
final double ecx = (ep[0].x + ep[1].x + ep[2].x + ep[3].x) / 4.0;
final double ecy = (ep[0].y + ep[1].y + ep[2].y + ep[3].y) / 4.0;
final double mAx = (ep[0].x + ep[1].x) / 2.0;
final double mAy = (ep[0].y + ep[1].y) / 2.0;
final double mBx = (ep[1].x + ep[2].x) / 2.0;
final double mBy = (ep[1].y + ep[2].y) / 2.0;
final double w =
2 * math.sqrt(math.pow(mAx - ecx, 2) + math.pow(mAy - ecy, 2));
final double h =
2 * math.sqrt(math.pow(mBx - ecx, 2) + math.pow(mBy - ecy, 2));
if (w <= 1 || h <= 1) continue;
// À quel point le contour ressemble-t-il vraiment à son ellipse ?
// On compare l'aire du contour à l'aire de l'ellipse ajustée.
final double ellipseArea = math.pi * (w / 2) * (h / 2);
if (ellipseArea <= 0) continue;
final double fitRatio = area / ellipseArea; // ~1 si bon ajustement
if (fitRatio < 0.7 || fitRatio > 1.3) continue;
// Score = taille de l'ellipse (on veut la cible la plus grande)
final double score = ellipseArea;
if (score > bestScore) {
bestScore = score;
bestEllipse = e;
}
}
if (bestEllipse == null) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Aucune cible circulaire détectée',
);
}
// ── 3. Extraire les 4 sommets de l'ellipse (robuste à la version d'API) ─
// RotatedRect.points renvoie les 4 coins de la boîte englobant l'ellipse.
// On en dérive nous-mêmes le centre, les demi-axes et l'orientation, ce
// qui évite de dépendre de la forme exacte de `.size` / `.center`
// (record vs objet) qui varie selon les versions d'opencv_dart.
final pts = bestEllipse.points; // List<Point2f> de 4 sommets
double px(int i) => pts[i].x;
double py(int i) => pts[i].y;
// Centre = moyenne des 4 sommets
final double cx = (px(0) + px(1) + px(2) + px(3)) / 4.0;
final double cy = (py(0) + py(1) + py(2) + py(3)) / 4.0;
// Milieux de deux côtés adjacents → extrémités des deux demi-axes
// Côté 0-1 et côté 1-2 (ordre des sommets d'un RotatedRect)
final double m01x = (px(0) + px(1)) / 2.0;
final double m01y = (py(0) + py(1)) / 2.0;
final double m12x = (px(1) + px(2)) / 2.0;
final double m12y = (py(1) + py(2)) / 2.0;
// Demi-axes = distance centre → milieu de chaque côté
final double axisA =
math.sqrt(math.pow(m01x - cx, 2) + math.pow(m01y - cy, 2));
final double axisB =
math.sqrt(math.pow(m12x - cx, 2) + math.pow(m12y - cy, 2));
final double majorAxis = math.max(axisA, axisB);
final double minorAxis = math.min(axisA, axisB);
if (majorAxis <= 1) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Ellipse dégénérée',
);
}
final double axisRatio = minorAxis / majorAxis; // 1 = cercle parfait
final double tiltDeg =
math.acos(axisRatio.clamp(0.0, 1.0)) * (180.0 / math.pi);
// Déjà quasiment de face → on ne touche pas (évite le flou inutile)
if (axisRatio >= minTiltRatioToRectify) {
cv.imwrite(outputPath, src);
return RectifyResult(
outputPath: outputPath,
rectified: false,
estimatedTiltDegrees: tiltDeg,
message: 'Cible déjà de face',
);
}
// ── 4. Construire la transformation de perspective ────────────────────
// Source : extrémités des deux axes de l'ellipse (4 points).
// Destination : extrémités des axes d'un cercle parfait centré.
// On obtient les extrémités en prolongeant centre→milieu-de-côté.
final srcPts = cv.VecPoint.fromList([
cv.Point((cx + (m01x - cx)).round(), (cy + (m01y - cy)).round()),
cv.Point((cx - (m01x - cx)).round(), (cy - (m01y - cy)).round()),
cv.Point((cx + (m12x - cx)).round(), (cy + (m12y - cy)).round()),
cv.Point((cx - (m12x - cx)).round(), (cy - (m12y - cy)).round()),
]);
// Cible : cercle parfait centré, rayon R, dans une image carrée.
// L'axe "A" (m01) devient l'axe horizontal, l'axe "B" (m12) le vertical.
final double out = outputSize.toDouble();
final double center = out / 2;
final double radius = (out / 2) / marginFactor;
final dstPts = cv.VecPoint.fromList([
cv.Point((center + radius).round(), center.round()),
cv.Point((center - radius).round(), center.round()),
cv.Point(center.round(), (center + radius).round()),
cv.Point(center.round(), (center - radius).round()),
]);
final cv.Mat transform = cv.getPerspectiveTransform(srcPts, dstPts);
final cv.Mat warped = cv.warpPerspective(
src,
transform,
(outputSize, outputSize),
flags: cv.INTER_LINEAR,
borderMode: cv.BORDER_CONSTANT,
);
cv.imwrite(outputPath, warped);
return RectifyResult(
outputPath: outputPath,
rectified: true,
estimatedTiltDegrees: tiltDeg,
message: 'Cible redressée (inclinaison ${tiltDeg.toStringAsFixed(1)}°)',
);
} catch (e) {
debugPrint('TargetRectify erreur: $e');
// En cas de pépin, on renvoie l'original pour ne jamais perdre la photo
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Erreur de traitement: $e',
);
}
}
}
+83 -1
View File
@@ -1,6 +1,7 @@
import 'dart:math';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';
@@ -9,6 +10,9 @@ import 'package:flutter/foundation.dart';
class WalletIdentityService {
static const String _prefsKey = 'wallet_identity_phrase';
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
static const String _bannedKey = 'wallet_is_banned';
static const String _banReasonKey = 'wallet_ban_reason';
static const String _serverUrlKey = 'ai_server_url';
// A standard list of 256 words (8 bits of entropy per word)
static const List<String> _wordList = [
@@ -40,18 +44,96 @@ class WalletIdentityService {
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
];
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA
/// Retourne l'URL de base du serveur configuré (ex: http://192.168.1.50:3000 ou http://10.0.2.2:3000)
Future<String> getServerBaseUrl() async {
final prefs = await SharedPreferences.getInstance();
final customUrl = prefs.getString(_serverUrlKey);
if (customUrl != null && customUrl.trim().isNotEmpty) {
return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
}
if (Platform.isAndroid) {
return 'http://10.0.2.2:3000';
}
return 'http://localhost:3000';
}
/// Définit une URL personnalisée pour le serveur IA
Future<void> setServerBaseUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final cleanUrl = url.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
await prefs.setString(_serverUrlKey, cleanUrl);
}
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA (et n'est pas banni)
Future<bool> isUploadEnabled() async {
final prefs = await SharedPreferences.getInstance();
final isBanned = prefs.getBool(_bannedKey) ?? false;
if (isBanned) return false;
return prefs.getBool(_uploadEnabledKey) ?? false;
}
/// Active ou désactive l'envoi de données
Future<void> setUploadEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
final isBanned = prefs.getBool(_bannedKey) ?? false;
if (isBanned) {
await prefs.setBool(_uploadEnabledKey, false);
return;
}
await prefs.setBool(_uploadEnabledKey, enabled);
}
/// Vérifie si ce wallet/utilisateur est banni en local
Future<bool> isBanned() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_bannedKey) ?? false;
}
/// Récupère le motif de bannissement enregistré
Future<String?> getBanReason() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_banReasonKey);
}
/// Enregistre l'état de bannissement et le motif
Future<void> setBanned(bool banned, {String? reason}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_bannedKey, banned);
if (banned) {
await prefs.setString(_banReasonKey, reason ?? 'Photos non conformes aux règles de tir');
await prefs.setBool(_uploadEnabledKey, false);
} else {
await prefs.remove(_banReasonKey);
}
}
/// Synchronise le statut de modération/bannissement avec le serveur backend
Future<bool> syncBanStatus() async {
try {
final phrase = await getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
final baseUrl = await getServerBaseUrl();
final res = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
const Duration(seconds: 4),
);
if (res.statusCode == 200) {
final data = jsonDecode(res.body);
final isBannedOnServer = data['is_banned'] == true;
if (isBannedOnServer) {
await setBanned(true, reason: data['ban_reason']);
} else {
await setBanned(false);
}
return isBannedOnServer;
}
} catch (e) {
debugPrint('Erreur synchro ban: $e');
}
return await isBanned();
}
/// Gets the unique 15-word identity phrase
Future<String> getIdentityPhrase() async {
final prefs = await SharedPreferences.getInstance();
@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
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
file_selector_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -7,12 +7,14 @@ import Foundation
import device_info_plus
import file_selector_macos
import share_plus
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}
+80 -24
View File
@@ -201,6 +201,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -225,6 +249,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -293,30 +325,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_mlkit_commons:
dependency: transitive
description:
name: google_mlkit_commons
sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
google_mlkit_document_scanner:
dependency: "direct main"
description:
name: google_mlkit_document_scanner
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
google_mlkit_object_detection:
dependency: "direct main"
description:
name: google_mlkit_object_detection
sha256: "9dd35886972e18747e22098f8ebee78d30716a99a789bb2e3a65a24229e031e7"
url: "https://pub.dev"
source: hosted
version: "0.15.1"
hooks:
dependency: transitive
description:
@@ -733,6 +741,22 @@ packages:
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:
dependency: "direct main"
description:
@@ -922,6 +946,38 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
+5 -11
View File
@@ -1,5 +1,5 @@
name: bully
description: "A new Flutter project."
description: "Application d'analyse de cibles de tir : capture, centrage, plotting manuel des impacts, scores et statistiques."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -38,11 +38,9 @@ dependencies:
cupertino_icons: ^1.0.8
sensors_plus: ^4.0.2
opencv_dart: ^2.1.0
google_mlkit_object_detection: ^0.15.0
# Image capture from camera/gallery
image_picker: ^1.2.1
google_mlkit_document_scanner: ^0.2.0
# Local database for history
sqflite: ^2.3.2
@@ -72,9 +70,8 @@ dependencies:
shared_preferences: ^2.5.5
crypto: ^3.0.7
camera: ^0.12.0+1
# Machine Learning for YOLOv8
# tflite_flutter: ^0.11.0
share_plus: ^13.3.0
file_selector: ^1.1.0
dev_dependencies:
flutter_test:
@@ -98,11 +95,8 @@ flutter:
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - assets/models/yolov8n_32.tflite
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/icons/
# An image asset can refer to one or more resolution-specific "variants", see
# 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
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
// Le montage complet de BullyApp exige les providers globaux et une base
// SQLite initialisée ; pour un test widget rapide et stable, on valide ici
// le rendu d'un composant autonome.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/app.dart';
import 'package:bully/features/home/widgets/stats_card.dart';
void main() {
testWidgets('App loads correctly', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const BullyApp());
testWidgets('StatsCard affiche le titre, la valeur et l\'icône',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: StatsCard(
icon: Icons.gps_fixed,
title: 'Tirs',
value: '42',
color: Colors.blue,
),
),
),
);
// Verify that the app title is displayed.
expect(find.text('Bully'), findsOneWidget);
expect(find.text('Tirs'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.byIcon(Icons.gps_fixed), findsOneWidget);
});
}
-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);
}

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