Compare commits

..
90 Commits
Author SHA1 Message Date
streaper2 7923d1b2b2 ci: correction compilation sqlite
Deploy Backendia / deploy (push) Successful in 3m0s
2026-08-26 08:34:54 +02:00
streaper2 7525c7e368 ci: delete de l'ancien container 2026-08-26 08:29:39 +02:00
streaper2 e111f76731 ci: fix port container
Deploy Backendia / deploy (push) Successful in 3s
2026-08-26 08:28:21 +02:00
streaper2 5d7d5e6b54 ci: fix node container
Deploy Backendia / deploy (push) Successful in 1m22s
2026-08-26 08:10:16 +02:00
streaper2 bc77462c27 ci: fix
Deploy Backendia / deploy (push) Failing after 1s
2026-08-26 08:09:07 +02:00
streaper2 4437a1f436 ci: changement host docker
Deploy Backendia / deploy (push) Canceled after 0s
2026-08-26 08:05:22 +02:00
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
qguillaume f0b15941cf fix: on revire ml kit on essaie d ameliorer la detection des cercles 2026-06-05 23:05:11 +02:00
qguillaume 9f9f9beb84 feat: target rectify service pour perfectionner la prise de photo avec detection 2026-06-05 22:27:53 +02:00
qguillaume c49c8b8c73 fix: cadre toujours orange 2026-06-05 22:06:05 +02:00
qguillaume 61b5fcfe22 fix: ajout untrack files 2026-06-05 21:30:59 +02:00
qguillaume f65c5e6a3f feat: object detection service 2026-06-05 21:18:48 +02:00
qguillaume d487c98e3d fix: parallelisme service permissif et correctif capture screen 2026-06-05 20:33:04 +02:00
qguillaume 9cb37fc303 feat: construction du service parallelisme 2026-06-05 19:47:18 +02:00
qguillaume 3ae9ceebc1 fix: lenteur de l appli capture screen 2026-06-05 17:38:25 +02:00
qguillaume 251d4bb599 fix: lenteur de l appli 2026-06-05 17:18:29 +02:00
qguillaume ccc6eb609a fix(analysis): correctif réel du zoom post prise de photo 2026-06-05 07:42:07 +02:00
qguillaume 9e0427f750 fix(analysis): normaliser les coordonnées de la cible pour annuler l'effet du zoom initial 2026-06-04 23:32:38 +02:00
qguillaume a5d0251545 fix(camera): synchroniser le ratio de visée 3/4 avec le capteur natif et corriger le décalage du crop 2026-05-27 17:44:57 +02:00
qguillaume 730629a031 fix(crop): stabiliser le rectangle géométrique de découpe et l'enchaînement des zooms 2026-05-27 15:36:56 +02:00
qguillaume 4d2ca2c94f refactor(crop_screen): restrict rotation bounds to 15 degrees with high-precision decimal steps 2026-05-27 14:48:57 +02:00
qguillaume ab45786d0c fix(analysis): appliquer la transformation globale sur le GestureDetector pour restaurer le drag-and-drop 2026-05-27 12:16:35 +02:00
qguillaume 95e62abe47 fix(analysis): appliquer la rotation du crop sur l'image en modes calibration et plotting 2026-05-27 12:11:00 +02:00
qguillaume 04c1204116 fix(calibration): préserver les rayons d'origine de l'IA lors du redimensionnement et corriger le reset 2026-05-27 11:56:07 +02:00
qguillaume 78c9fc96c9 feat(session): ajout du sélecteur de date et persistance dans l'historique de tir 2026-05-27 00:57:26 +02:00
qguillaume e570bd4296 fix(calibration): application de la rotation et du centrage sur l'image globale 2026-05-27 00:23:48 +02:00
qguillaume 02c0d449e4 fix(calibration): application du crop sur l'écran de calibration 2026-05-27 00:03:29 +02:00
qguillaume eabe7511bd feat(crop): masque opaque à coins droits pour cacher l'extérieur du cadre de découpe 2026-05-26 23:41:06 +02:00
qguillaume 620e12a1fd feat(crop): ajout de lignes de visée perpendiculaires sur toute la zone pour aligner la cible 2026-05-26 23:36:50 +02:00
qguillaume 622f2936de ui(plotting): décalage visuel de l'impact lors du glisser pour éviter de le cacher avec le doigt 2026-05-26 23:23:45 +02:00
qguillaume 814f778e6c feat(crop): remplacement de la rotation à deux doigts par une jauge précise centrée à zéro 2026-05-26 22:52:59 +02:00
qguillaume 2f493ef622 feat(setup): choix d'arme obligatoire, redirection armurerie et max balles à 50 2026-05-26 22:41:44 +02:00
qguillaume 33e1522df0 reword: Démarrer la session (home_screen) devient Nouvelle session 2026-05-26 22:29:37 +02:00
qguillaume 653264c6a1 Refactor: Amélioration globale de l'expérience de cadrage et de l'interface de capture 2026-05-26 18:09:39 +02:00
qguillaume 750732eafe Feat: Ajout de la rotation à deux doigts de l'image sur l'écran de Crop 2026-05-26 17:07:16 +02:00
qguillaume f40bfc0ba7 Feat: Ajout du glisser-déposer par appui long pour repositionner les impacts de tir 2026-05-26 17:03:21 +02:00
qguillaume b721583e98 Feat: Ajout du double-tap sur l'écran d'analyse pour l'édition rapide des impacts 2026-05-26 16:47:37 +02:00
qguillaume 18c01c3767 Style: Optimisation des curseurs de calibration et bridage des limites 2026-05-26 16:40:14 +02:00
qguillaume 0af6a0d8e2 Feat: Bouton de clôture de session (80/20) sur l'accueil et reset de l'espacement 2026-05-26 14:59:25 +02:00
qguillaume fbd631c8e4 Workflow: Inversion du flux pour calibrer la cible avant d'afficher l'écran de Plotting 2026-05-25 13:52:20 +02:00
qguillaume 3088c5ee91 Refactor: Ajustement des valeurs limites de taille (0.5 à 1.1) pour synchroniser le slider et le geste tactile 2026-05-25 13:14:51 +02:00
qguillaume fe6d52902b Refactor: Optimisation du range du slider de Taille pour améliorer la précision de calibration 2026-05-25 13:00:04 +02:00
qguillaume 48b8313be8 Feat: Ajout d'un switch pour masquer ou afficher le slider d'espacement de la cible 2026-05-25 03:12:37 +02:00
qguillaume a9ff826246 Design: Remplacement du point rouge par une mire vert fluo interne dans le viseur 2026-05-25 02:59:46 +02:00
qguillaume f149fe092b Fix: Passage à l'appareil photo classique et suppression de l'auto-capture 2026-05-25 02:43:31 +02:00
102 changed files with 8866 additions and 6067 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:*)"
]
}
}
+25
View File
@@ -0,0 +1,25 @@
name: Deploy Backendia
on:
push:
branches:
- main
jobs:
deploy:
# Correspond au label 'docker:host' de votre runner
runs-on: docker
steps:
- name: 📥 Récupération du code
run: |
echo "📥 Récupération du code..."
git clone --depth 1 --branch ${{ github.ref_name }} "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" .
- name: 🚀 Build et Déploiement Docker
run: |
echo "🚀 Démarrage du déploiement..."
docker rm -f backendia-prod 2>/dev/null || true
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans --force-recreate
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
+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 && npm rebuild sqlite3 --build-from-source
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

+21
View File
@@ -0,0 +1,21 @@
name: backendia
services:
backendia:
build:
context: ./backendia
dockerfile: Dockerfile
container_name: backendia-prod
restart: unless-stopped
ports:
- "3005: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'),
),
],
),
);
}
}
+98 -10
View File
@@ -10,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();
@@ -19,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!;
}
@@ -285,9 +293,14 @@ class DatabaseHelper {
Future<int> insertSession(Session session) async {
final db = await database;
return await db.transaction((txn) async {
// INTERCEPTION ET FIX DE LA DATE ICI :
// On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet
final Map<String, dynamic> sessionMap = Map.from(session.toMap());
sessionMap['created_at'] = session.createdAt.toIso8601String();
await txn.insert(
AppConstants.sessionsTable,
session.toMap(),
sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct !
conflictAlgorithm: ConflictAlgorithm.replace,
);
@@ -367,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 {
@@ -504,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(
@@ -513,6 +590,16 @@ class DatabaseHelper {
);
}
/// Toutes les entrées de maintenance, armes confondues (export/sauvegarde).
Future<List<MaintenanceEntry>> getAllMaintenance() async {
final db = await database;
final maps = await db.query(
AppConstants.maintenanceTable,
orderBy: 'date DESC',
);
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
}
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
final db = await database;
final maps = await db.query(
@@ -537,5 +624,6 @@ class DatabaseHelper {
final db = await database;
await db.close();
_database = null;
_initFuture = null;
}
}
+46 -2
View File
@@ -27,6 +27,7 @@ class SessionRepository {
required List<TargetAnalysis> analyses,
String? notes,
int distance = 25,
DateTime? date,
}) async {
final session = Session(
id: id ?? _uuid.v4(),
@@ -34,7 +35,7 @@ class SessionRepository {
weaponId: weaponId,
maxShotsPerTarget: maxShotsPerTarget,
analyses: analyses,
createdAt: DateTime.now(),
createdAt: date ?? DateTime.now(),
notes: notes,
distance: distance,
);
@@ -130,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();
}
@@ -169,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);
}
@@ -181,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);
@@ -202,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);
}
+83 -496
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,44 +13,35 @@ 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;
String? _imagePath;
TargetType? _targetType;
// Target detection results
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
double _cropRotation = 0.0;
// Target calibration
double _targetCenterX = 0.5;
double _targetCenterY = 0.5;
double _targetRadius = 0.4;
@@ -68,20 +59,12 @@ class AnalysisProvider extends ChangeNotifier {
// Grouping results
GroupingResult? _groupingResult;
// Reference-based detection
List<Shot> _referenceImpacts = [];
ImpactCharacteristics? _learnedCharacteristics;
// Distortion correction
bool _distortionCorrectionEnabled = false;
DistortionParameters? _distortionParams;
String? _correctedImagePath;
// Getters
AnalysisState get state => _state;
String? get errorMessage => _errorMessage;
String? get imagePath => _imagePath;
TargetType? get targetType => _targetType;
double get cropRotation => _cropRotation; // Getter pour le Plotting
double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius;
@@ -96,30 +79,18 @@ class AnalysisProvider extends ChangeNotifier {
int get totalScore => _scoreResult?.totalScore ?? 0;
int get shotCount => _shots.length;
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
// Distortion correction getters
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
DistortionParameters? get distortionParams => _distortionParams;
String? get correctedImagePath => _correctedImagePath;
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
/// Modifie et mémorise la rotation de l'image pour le Plotting
void setCropRotation(double rotation) {
_cropRotation = rotation;
notifyListeners();
}
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath
: _imagePath;
/// Analyze an image
///
/// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set.
/// Charge l'image et initialise les paramètres de cible par défaut.
/// Le placement des impacts et la calibration se font ensuite manuellement.
Future<void> analyzeImage(
String imagePath,
TargetType targetType, {
bool autoAnalyze = true,
Offset? manualCenter,
}) async {
_state = AnalysisState.loading;
@@ -137,53 +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;
}
// Detect target and impacts
final result = _detectionService.detectTarget(imagePath, targetType);
if (!result.success) {
_state = AnalysisState.error;
_errorMessage = result.errorMessage;
notifyListeners();
return;
}
_targetCenterX = result.centerX;
_targetCenterY = result.centerY;
_targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts
_shots = result.impacts.map((impact) {
return Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
analysisId: '', // Will be set when saving
);
}).toList();
// Calculate scores
_recalculateScores();
// Calculate grouping
_recalculateGrouping();
_state = AnalysisState.success;
notifyListeners();
} catch (e) {
@@ -204,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();
@@ -235,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,
@@ -502,17 +170,20 @@ class AnalysisProvider extends ChangeNotifier {
double radius, {
int? ringCount,
List<double>? ringRadii,
double zoomScale = 1.0,
Offset offset = Offset.zero,
}) {
_targetCenterX = centerX;
_targetCenterY = centerY;
_targetInnerRadius = innerRadius;
_targetRadius = radius;
_targetCenterX = (centerX - offset.dx) / zoomScale;
_targetCenterY = (centerY - offset.dy) / zoomScale;
_targetRadius = radius / zoomScale;
_targetInnerRadius = innerRadius / zoomScale;
if (ringCount != null) {
_ringCount = ringCount;
}
if (ringRadii != null) {
// CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement !
_ringRadii = ringRadii;
}
// Recalculate all shot scores based on new target position
_shots = _shots.map((shot) {
@@ -524,119 +195,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio =
1.0; // The corrected image is always square (side x side)
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
debugPrint('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
notifyListeners();
}
/// Applique la correction de distorsion à l'image
/// Crée une nouvelle image corrigée et la sauvegarde
Future<void> applyDistortionCorrection() async {
if (_imagePath == null || _distortionParams == null) return;
try {
_correctedImagePath = await _distortionService.applyCorrection(
_imagePath!,
_distortionParams!,
);
_distortionCorrectionEnabled = true;
notifyListeners();
} catch (e) {
_errorMessage = 'Erreur lors de la correction: $e';
notifyListeners();
}
}
/// Active ou désactive l'affichage de l'image corrigée
void setDistortionCorrectionEnabled(bool enabled) {
if (enabled && _correctedImagePath == null && _distortionParams != null) {
// Si on active mais pas encore d'image corrigée, la créer
applyDistortionCorrection();
} else {
_distortionCorrectionEnabled = enabled;
notifyListeners();
}
}
/// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel)
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
// 2. Vérification si une correction est réellement nécessaire
if (_distortionParams != null && _distortionParams!.needsCorrection) {
// 3. Application immédiate de la transformation (méthode asynchrone)
await applyDistortionCorrection();
} else {
notifyListeners(); // On prévient quand même si pas de correction
}
}
Future<void> runFullDistortionWorkflow() async {
_state = AnalysisState.loading; // Affiche un spinner sur votre UI
notifyListeners();
try {
calculateDistortion(); // Calcule les paramètres
await applyDistortionCorrection(); // Génère le fichier corrigé
_distortionCorrectionEnabled = true; // Active l'affichage
_state = AnalysisState.success;
} catch (e) {
_errorMessage = "Erreur de rendu : $e";
_state = AnalysisState.error;
} finally {
notifyListeners();
}
}
int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) {
return _scoreCalculatorService.calculateConcentricScore(
@@ -680,49 +238,56 @@ 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'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.",
);
}
// Identifiant d'analyse temporaire
final analysisId = _shots.isNotEmpty && _shots.first.analysisId.isNotEmpty
? _shots.first.analysisId
: 'analysis_${DateTime.now().millisecondsSinceEpoch}';
final service = AiExportService(); // Local instanciation for simplicity
final service = AiExportService();
_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 (legacy standalone flow)
/// Save the session
Future<TargetAnalysis> saveSession({
String? notes,
String? sessionId,
String? weaponName,
String? weaponId,
int? distance,
DateTime? date,
}) async {
if (_imagePath == null || _targetType == null) {
throw Exception('Cannot save: missing image or target type');
@@ -746,8 +311,12 @@ class AnalysisProvider extends ChangeNotifier {
if (sessionId != null && sessionId != 'standalone') {
final existingSession = await _sessionRepository.getSession(sessionId);
if (existingSession != null) {
await _sessionRepository.addAnalysisToSession(sessionId, targetAnalysis);
await _sessionRepository.addAnalysisToSession(
sessionId,
targetAnalysis,
);
} else {
// CORRECTION : Utilise la date injectée plutôt que DateTime.now()
await _sessionRepository.createSession(
id: sessionId,
weapon: weaponName ?? 'Inconnue',
@@ -756,9 +325,11 @@ class AnalysisProvider extends ChangeNotifier {
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
date: date ?? DateTime.now(),
);
}
} else {
// CORRECTION : S'applique aussi au flux alternatif sans ID préalable
await _sessionRepository.createSession(
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
@@ -766,6 +337,7 @@ class AnalysisProvider extends ChangeNotifier {
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
date: date ?? DateTime.now(),
);
}
@@ -779,6 +351,7 @@ class AnalysisProvider extends ChangeNotifier {
_errorMessage = null;
_imagePath = null;
_targetType = null;
_cropRotation = 0.0;
_targetCenterX = 0.5;
_targetCenterY = 0.5;
_targetRadius = 0.4;
@@ -789,11 +362,25 @@ class AnalysisProvider extends ChangeNotifier {
_shots = [];
_scoreResult = null;
_groupingResult = null;
_referenceImpacts = [];
_learnedCharacteristics = null;
_distortionCorrectionEnabled = false;
_distortionParams = null;
_correctedImagePath = null;
notifyListeners();
}
/// Met à jour la position d'un impact de tir en direct (Glisser-Déposer).
void updateShotPosition(String shotId, double newX, double newY) {
final index = _shots.indexWhere((s) => s.id == shotId);
if (index != -1) {
// 1. On calcule dynamiquement le nouveau score de la zone survolée
final newScore = _calculateShotScore(newX, newY);
// 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
// 3. On applique les deux méthodes natives de recalcul de ton application
_recalculateScores();
_recalculateGrouping();
// 4. On demande à l'écran de se redessiner en direct
notifyListeners();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
@@ -51,10 +59,19 @@ class TargetCalibrationState extends State<TargetCalibration> {
late int _ringCount;
late List<double> _ringRadii;
// Variable dédiée pour piloter la jauge orange sans conflit
late double _currentEspacementRatio;
bool _isDraggingCenter = false;
bool _isDraggingRadius = false;
bool _isDraggingInnerRadius = false;
bool _showEspacement = false;
double _baseRadiusBeforeScale = 1.0;
// SAUVEGARDE CRITIQUE : Pour mémoriser la structure d'origine de l'IA
List<double>? _originalRingRadii;
@override
void initState() {
super.initState();
@@ -63,19 +80,37 @@ class TargetCalibrationState extends State<TargetCalibration> {
_radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount;
// On mémorise la configuration d'usine de l'image
if (widget.initialRingRadii != null) {
_originalRingRadii = List.from(widget.initialRingRadii!);
}
// Initialisation du ratio par défaut
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
_initRingRadii();
}
/// 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}) {
if (!forceRecalculate && widget.initialRingRadii != null &&
widget.initialRingRadii!.length == _ringCount) {
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
_ringRadii = List.from(_originalRingRadii!);
} else if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!);
} else {
// Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius;
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
});
}
}
@@ -85,12 +120,10 @@ class TargetCalibrationState extends State<TargetCalibration> {
super.didUpdateWidget(oldWidget);
bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX &&
!_isDraggingCenter) {
if (widget.initialCenterX != oldWidget.initialCenterX && !_isDraggingCenter) {
_centerX = widget.initialCenterX;
}
if (widget.initialCenterY != oldWidget.initialCenterY &&
!_isDraggingCenter) {
if (widget.initialCenterY != oldWidget.initialCenterY && !_isDraggingCenter) {
_centerY = widget.initialCenterY;
}
if (widget.initialRingCount != oldWidget.initialRingCount) {
@@ -101,12 +134,20 @@ class TargetCalibrationState extends State<TargetCalibration> {
_radius = widget.initialRadius;
shouldReinit = true;
}
if (widget.initialInnerRadius != oldWidget.initialInnerRadius &&
!_isDraggingInnerRadius) {
if (widget.initialInnerRadius != oldWidget.initialInnerRadius && !_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius;
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii) {
// On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
// réellement d'une nouvelle détection (ils diffèrent de notre état courant).
// Sinon il s'agit de l'écho de notre propre _notifyChange (aller-retour via
// le provider) : le clobber effacerait le profil d'origine et casserait le
// bouton de réinitialisation de l'espacement.
if (widget.initialRingRadii != oldWidget.initialRingRadii &&
widget.initialRingRadii != null &&
!listEquals(widget.initialRingRadii, _ringRadii)) {
_originalRingRadii = List.from(widget.initialRingRadii!);
shouldReinit = true;
}
@@ -121,12 +162,23 @@ class TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) {
final size = constraints.biggest;
return Stack(
children: [
GestureDetector(
onPanStart: (details) => _onPanStart(details, size),
onPanUpdate: (details) => _onPanUpdate(details, size),
onPanEnd: (_) => _onPanEnd(),
// 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;
final tapY = details.localFocalPoint.dy / size.height;
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
setState(() {
_isDraggingCenter = true;
});
}
},
onScaleUpdate: (details) => _onScaleUpdate(details, size),
onScaleEnd: (_) => _onScaleEnd(),
child: CustomPaint(
size: size,
painter: _CalibrationPainter(
@@ -138,91 +190,88 @@ class TargetCalibrationState extends State<TargetCalibration> {
ringRadii: _ringRadii,
targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter,
isDraggingRadius: false, // Plus de drag direct du rayon
isDraggingRadius: false,
isDraggingInnerRadius: false,
),
),
),
// Slider pour le rayon (agrandissement)
Positioned(
top: 10,
left: 40,
right: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
Expanded(
child: Slider(
value: _radius.clamp(0.05, 3.0),
min: 0.05,
max: 3.0,
activeColor: AppTheme.primaryColor,
onChanged: (value) {
setState(() {
_radius = value;
// On force le recalcul car on change la taille
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
],
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.compress, color: Colors.white, size: 16),
Expanded(
child: Slider(
value: (_innerRadius / _radius).clamp(0.01, 0.9),
min: 0.01,
max: 0.9,
activeColor: Colors.orange,
onChanged: (value) {
setState(() {
// On ajuste l'innerRadius comme un ratio du radius global
_innerRadius = _radius * value;
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.expand, color: Colors.white, size: 16),
],
),
),
],
),
),
],
);
},
);
}
// ---------------------------------------------------------------------------
// API publique pilotée par l'écran hôte (panneau de réglages hors de l'image)
// ---------------------------------------------------------------------------
/// Espacement courant (rayon du premier anneau, en fraction du rayon global).
double get spacingRatio => _currentEspacementRatio;
/// Mode d'espacement manuel actif ou non.
bool get isSpacingModeEnabled => _showEspacement;
/// Applique une nouvelle taille globale (rayon normalisé).
void setRadius(double value) {
setState(() {
_radius = value.clamp(minRadius, maxRadius);
_innerRadius = _radius * _currentEspacementRatio;
// Si le mode avancé n'est pas coché, on applique la taille pure sans
// détruire le ratio d'origine.
_initRingRadii(forceRecalculate: _showEspacement);
});
_notifyChange();
}
/// Agrandit / réduit la cible de [deltaPixels] pixels.
///
/// La conversion utilise la plus petite dimension de [size], exactement comme
/// le painter, pour que le pas corresponde bien à un pixel à l'écran.
void adjustRadiusByPixels(double deltaPixels, Size size) {
final minDim = size.width < size.height ? size.width : size.height;
if (minDim <= 0) return;
setRadius(_radius + deltaPixels / minDim);
}
/// Active ou non le réglage manuel de l'espacement.
///
/// À la désactivation, on restaure la configuration d'usine.
void setSpacingMode(bool enabled) {
setState(() {
_showEspacement = enabled;
if (!enabled) {
_initRingRadii(forceRecalculate: false);
}
});
_notifyChange();
}
/// Applique un nouvel espacement entre les anneaux.
void setSpacingRatio(double value) {
setState(() {
_currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
}
/// Restaure le vrai profil d'usine détecté sur l'image.
void resetSpacing() {
setState(() {
if (_originalRingRadii != null) {
_initRingRadii(forceRecalculate: false);
if (_ringRadii.isNotEmpty) {
_currentEspacementRatio = _ringRadii.first;
_innerRadius = _radius * _currentEspacementRatio;
}
} else {
_currentEspacementRatio = 0.1;
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: true);
}
});
_notifyChange();
}
Widget buildDirectionalControls(BuildContext context, Size size) {
return Container(
padding: const EdgeInsets.all(8),
@@ -231,42 +280,55 @@ class TargetCalibrationState extends State<TargetCalibration> {
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Column(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(
Icons.keyboard_arrow_up,
() => _moveCenterByPixels(0, -1, size),
),
// 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)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(
Icons.keyboard_arrow_left,
() => _moveCenterByPixels(-1, 0, size),
),
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
const SizedBox(width: 40),
_buildDirectionButton(
Icons.keyboard_arrow_right,
() => _moveCenterByPixels(1, 0, size),
),
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
],
),
_buildDirectionButton(
Icons.keyboard_arrow_down,
() => _moveCenterByPixels(0, 1, size),
),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
],
),
const SizedBox(width: 12),
// Agrandit la cible d'un pixel.
_buildSizeButton('+', () => adjustRadiusByPixels(1, size)),
],
),
);
}
/// Bouton / + de part et d'autre de la croix : ajuste la TAILLE de la cible.
Widget _buildSizeButton(String sign, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
child: IconButton(
icon: Text(
sign,
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
tooltip: sign == '+' ? 'Agrandir la cible' : 'Réduire la cible',
),
);
}
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
margin: const EdgeInsets.all(2),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 28),
@@ -296,36 +358,23 @@ class TargetCalibrationState extends State<TargetCalibration> {
_notifyChange();
}
void _onPanStart(DragStartDetails details, Size size) {
final tapX = details.localPosition.dx / size.width;
final tapY = details.localPosition.dy / size.height;
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
setState(() {
_isDraggingCenter = true;
});
}
}
void _onPanUpdate(DragUpdateDetails details, Size size) {
final deltaX = details.delta.dx / size.width;
final deltaY = details.delta.dy / size.height;
final minDim = math.min(size.width, size.height);
setState(() {
if (_isDraggingCenter) {
// Move center
if (details.pointerCount == 2) {
_radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius);
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: _showEspacement);
} else if (_isDraggingCenter) {
final deltaX = details.focalPointDelta.dx / size.width;
final deltaY = details.focalPointDelta.dy / size.height;
_centerX = _centerX + deltaX;
_centerY = _centerY + deltaY;
}
});
_notifyChange();
}
void _onPanEnd() {
void _onScaleEnd() {
setState(() {
_isDraggingCenter = false;
_isDraggingRadius = false;
@@ -370,7 +419,6 @@ class _CalibrationPainter extends CustomPainter {
final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim;
final innerRadiusPx = innerRadius * minDim;
if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
@@ -378,37 +426,17 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
}
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) {
final crosshairLinePaint = Paint()
..color = Colors.red.withValues(alpha: 0.5)
..strokeWidth = 1;
canvas.drawLine(
Offset(0, centerPx.dy),
Offset(size.width, centerPx.dy),
crosshairLinePaint,
);
canvas.drawLine(
Offset(centerPx.dx, 0),
Offset(centerPx.dx, size.height),
crosshairLinePaint,
);
final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
}
// Draw center handle
_drawCenterHandle(canvas, centerPx);
// Draw instructions
_drawInstructions(canvas, size);
}
void _drawConcentricZones(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) {
final ratio = i / ringCount;
@@ -431,11 +459,8 @@ class _CalibrationPainter extends CustomPainter {
..strokeWidth = 1
..color = Colors.red.withValues(alpha: 0.8);
// Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) {
final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i];
@@ -443,22 +468,14 @@ class _CalibrationPainter extends CustomPainter {
canvas.drawCircle(center, zoneRadius, strokePaint);
}
// Draw zone labels (only if within visible area)
final textPainter = TextPainter(textDirection: TextDirection.ltr);
for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0;
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final textSpanRatio = i > 0 ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) : 0.0;
final zoneRadius = baseRadius * (ringRadius + textSpanRatio) / 2;
// Score: center = 10, decrement by 1 for each ring
final score = 10 - i;
// Only draw label if it's within the visible area
final labelX = center.dx + zoneRadius;
if (labelX < 0 || labelX > size.width) continue;
@@ -473,102 +490,44 @@ class _CalibrationPainter extends CustomPainter {
);
textPainter.layout();
// Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(
canvas,
Offset(labelX - textPainter.width / 2, labelY),
);
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
}
}
}
void _drawSilhouetteZones(
Canvas canvas,
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now)
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
final silhouetteWidth = radius * 0.8;
final silhouetteHeight = radius * 2;
paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect(
Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint,
);
canvas.drawRect(Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), paint);
}
void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle
final outerPaint = Paint()
..color = isDraggingCenter ? Colors.redAccent : Colors.red
..style = PaintingStyle.stroke
..strokeWidth = 3;
final outerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.stroke..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint);
// Inner dot
final innerPaint = Paint()
..color = isDraggingCenter ? Colors.redAccent : Colors.red
..style = PaintingStyle.fill;
final innerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint);
// Crosshair
final crossPaint = Paint()
..color = isDraggingCenter ? Colors.redAccent : Colors.red
..strokeWidth = 2;
canvas.drawLine(
Offset(center.dx - 20, center.dy),
Offset(center.dx - 8, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx + 8, center.dy),
Offset(center.dx + 20, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy - 20),
Offset(center.dx, center.dy - 8),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy + 8),
Offset(center.dx, center.dy + 20),
crossPaint,
);
final crossPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy + 4), Offset(center.dx, center.dy + 20), crossPaint);
}
void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon';
final textPainter = TextPainter(
text: TextSpan(
text: instruction,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
backgroundColor: Colors.black54,
),
),
text: TextSpan(text: instruction, style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 12, backgroundColor: Colors.black54)),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset((size.width - textPainter.width) / 2, size.height - 30),
);
textPainter.paint(canvas, Offset((size.width - textPainter.width) / 2, size.height - 30));
}
@override
@@ -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
+318 -83
View File
@@ -27,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
@@ -36,6 +41,9 @@ class _CropScreenState extends State<CropScreen> {
Offset _startOffset = Offset.zero;
Offset _startFocalPoint = Offset.zero;
// PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés
double _rotation = 0.0;
bool _isLoading = false;
bool _imageLoaded = false;
Size? _imageSize;
@@ -78,15 +86,64 @@ class _CropScreenState extends State<CropScreen> {
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
actions: [
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Réinitialiser l\'orientation',
onPressed: () {
setState(() {
_rotation = 0.0;
_scale = 1.0;
_offset = Offset.zero;
_initializeImagePosition();
});
},
),
],
),
body: _isLoading
? 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),
@@ -97,27 +154,66 @@ class _CropScreenState extends State<CropScreen> {
),
),
),
),
),
),
// Bouton d'aide / Modifier
const SizedBox(height: 8),
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel,
// encadrée par les deux boutons de rotation fine.
_buildDirectionalPad(),
const SizedBox(height: 16),
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Icon(Icons.center_focus_strong, color: Colors.redAccent, size: 20),
const SizedBox(width: 8),
const Text(
'Rotation de l\'image',
style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600),
),
Text(
'Alignez le centre de la cible sur la croix',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
'${_rotation.toStringAsFixed(1)}°',
style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 4),
Text(
'Zoomez pour plus de précision',
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
Row(
children: [
// Les icônes de sens de rotation ont rejoint les boutons / +
// de la croix directionnelle.
Expanded(
child: Slider(
value: _rotation,
min: _minRotation,
max: _maxRotation,
divisions: 300,
label: '${_rotation.toStringAsFixed(1)}°',
activeColor: const Color(0xFF1A73E8),
inactiveColor: Colors.white12,
onChanged: (double value) {
setState(() {
_rotation = value;
});
},
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
onPressed: () {
setState(() {
_rotation = 0.0;
});
},
),
],
),
],
),
@@ -125,7 +221,7 @@ class _CropScreenState extends State<CropScreen> {
// Boutons du bas
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
child: Row(
children: [
Expanded(
@@ -149,7 +245,7 @@ class _CropScreenState extends State<CropScreen> {
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Analyser', style: TextStyle(fontWeight: FontWeight.bold)),
child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
@@ -165,10 +261,11 @@ class _CropScreenState extends State<CropScreen> {
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// Le carré de crop occupe presque tout l'espace disponible
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95;
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
// visée = toute la zone visible → aucun bord noir autour du cadre.
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
if (_scale == 1.0 && _offset == Offset.zero) {
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
_initializeImagePosition();
}
@@ -177,22 +274,73 @@ class _CropScreenState extends State<CropScreen> {
onScaleUpdate: _onScaleUpdate,
child: Stack(
children: [
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
Positioned.fill(
child: Center(
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale),
..scaleByDouble(_scale, _scale, _scale, 1.0)
..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
fit: BoxFit.contain,
fit: BoxFit.cover,
width: _viewportSize.width,
height: _viewportSize.height,
),
),
),
),
// 2. Les lignes vertes de visée prolongées
Positioned.fill(
child: IgnorePointer(
child: Stack(
children: [
Center(
child: Container(
width: double.infinity,
height: 1.5,
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
],
),
),
),
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
Positioned.fill(
child: IgnorePointer(
child: ColorFiltered(
colorFilter: ColorFilter.mode(
const Color(0xFF101214),
BlendMode.srcOut,
),
child: Stack(
children: [
Center(
child: Container(
width: _cropSize,
height: _cropSize,
color: Colors.black,
),
),
],
),
),
),
),
// 4. Ton overlay avec les coins blancs
Positioned.fill(
child: IgnorePointer(
child: CropOverlay(cropSize: _cropSize, showGrid: false),
@@ -208,28 +356,126 @@ class _CropScreenState extends State<CropScreen> {
void _initializeImagePosition() {
if (_imageSize == null) return;
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 minDisplayDim = math.min(displayWidth, displayHeight);
_scale = widget.initialScale ?? (_cropSize / minDisplayDim);
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
_scale = widget.initialScale ?? 1.0;
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
if (widget.initialOffset != null) {
_offset = widget.initialOffset!;
} else {
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
}
}
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
// Les 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) {
_baseScale = _scale;
_startFocalPoint = details.focalPoint;
@@ -247,21 +493,49 @@ class _CropScreenState extends State<CropScreen> {
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
final cropRect = _calculateCropRect();
// On calcule le centre relatif basé sur le centrage utilisateur
final targetCenterX = cropRect.x + cropRect.width / 2;
final targetCenterY = cropRect.y + cropRect.height / 2;
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
final double displayPerSourcePx = math.max(
_viewportSize.width / _imageSize!.width,
_viewportSize.height / _imageSize!.height,
);
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
// sont remplis en noir → la position choisie est respectée à l'identique.
final croppedImagePath = await _cropService.cropViewport(
sourcePath: widget.imagePath,
offsetDx: _offset.dx,
offsetDy: _offset.dy,
displayPerSourcePx: displayPerSourcePx,
cropSizeDisplay: _cropSize,
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
// il n'agrandit pas le rendu de sortie.
zoomScale: _scale,
rotationDegrees: _rotation,
);
const targetCenterX = 0.5;
const targetCenterY = 0.5;
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: widget.imagePath,
imagePath: croppedImagePath,
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
originalImagePath: widget.imagePath,
targetType: widget.targetType,
targetCenter: Offset(targetCenterX, targetCenterY),
cropScale: _scale,
cropOffset: _offset,
initialCenterX: targetCenterX,
initialCenterY: targetCenterY,
cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
),
),
);
@@ -269,49 +543,10 @@ class _CropScreenState extends State<CropScreen> {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
);
}
}
}
CropRect _calculateCropRect() {
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale;
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2;
final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2;
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropWidth = _cropSize / scaledWidth;
final relCropHeight = _cropSize / scaledHeight;
return CropRect(
x: relCropLeft,
y: relCropTop,
width: relCropWidth,
height: relCropHeight,
);
}
}
+22 -17
View File
@@ -68,7 +68,7 @@ class _CropOverlayPainter extends CustomPainter {
_drawGrid(canvas, cropRect);
}
// Dessiner le point central (croix)
// Dessiner le point central (croix interne vert fluo)
_drawCenterPoint(canvas, cropRect);
}
@@ -165,35 +165,40 @@ class _CropOverlayPainter extends CustomPainter {
}
void _drawCenterPoint(Canvas canvas, Rect rect) {
// Peinture de la croix interne (Vert Fluo)
final centerPaint = Paint()
..color = Colors.white.withValues(alpha: 0.8)
..color = const Color(0xFF00FF00)
..style = PaintingStyle.stroke
..strokeWidth = 2;
..strokeWidth = 2.0;
const size = 10.0;
// Cercle extérieur (Vert Fluo fin)
final circlePaint = Paint()
..color = const Color(0xFF00FF00).withValues(alpha: 0.9)
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
const circleRadius = 10.0; // Augmenté légèrement à 10 pour que la croix respire
final centerX = rect.center.dx;
final centerY = rect.center.dy;
// Ligne horizontale
// 1. Dessiner le cercle de contour
canvas.drawCircle(rect.center, circleRadius, circlePaint);
// 2. Dessiner la croix UNIQUEMENT à l'intérieur du cercle
// Ligne horizontale (de gauche à droite, reste à l'intérieur)
canvas.drawLine(
Offset(centerX - size, centerY),
Offset(centerX + size, centerY),
Offset(centerX - circleRadius, centerY),
Offset(centerX + circleRadius, centerY),
centerPaint,
);
// Ligne verticale
// Ligne verticale (de haut en bas, reste à l'intérieur)
canvas.drawLine(
Offset(centerX, centerY - size),
Offset(centerX, centerY + size),
Offset(centerX, centerY - circleRadius),
Offset(centerX, centerY + circleRadius),
centerPaint,
);
// Petit cercle central pour précision (optionnel, mais aide à viser)
canvas.drawCircle(
rect.center,
2,
Paint()..color = Colors.red.withValues(alpha: 0.6),
);
}
@override
+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),
),
),
],
),
+133 -7
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();
@@ -125,24 +170,105 @@ class _HomeScreenState extends State<HomeScreen> {
final sessionProvider = context.watch<SessionProvider>();
final isSessionActive = sessionProvider.isSessionActive;
// Si aucune session n'est active, on affiche juste le bouton de départ habituel
if (!isSessionActive) {
return ElevatedButton.icon(
onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom(
backgroundColor: isSessionActive ? AppTheme.secondaryColor : AppTheme.primaryColor,
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: Icon(isSessionActive ? Icons.play_circle_outline : Icons.add_circle_outline, size: 28),
label: Text(
isSessionActive ? 'Continuer la session' : 'Démarrer la session',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
icon: const Icon(Icons.add_circle_outline, size: 28),
label: const Text(
'Nouvelle session',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
);
}
// MODIFICATION : Alignement sur une seule ligne (80% / 20%) avec une croix pour l'annulation
return Row(
children: [
// 80% de largeur pour continuer la session
Expanded(
flex: 8,
child: ElevatedButton.icon(
onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.play_circle_outline, size: 28),
label: const Text(
'Continuer la session',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 12),
// 20% de largeur pour terminer/annuler la session (Bouton Croix)
Expanded(
flex: 2,
child: OutlinedButton(
onPressed: () {
// Boite de dialogue de confirmation pour éviter les erreurs
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Terminer la session ?'),
content: const Text('Êtes-vous sûr de vouloir clôturer la session en cours ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('ANNULER'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
// Clôture propre de la session dans le State global
context.read<SessionProvider>().endSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Session terminée avec succès !'),
backgroundColor: AppTheme.successColor,
),
);
// Fin de session : on bascule sur les statistiques.
openMainTab(mainTabStats);
},
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
),
],
);
},
);
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 20), // Même hauteur que le bouton continuer
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
child: const Icon(Icons.close, size: 28),
),
),
],
);
}
Widget _buildStatsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -229,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,
),
+4 -3
View File
@@ -1,9 +1,9 @@
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;
DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName;
String? _currentWeaponId;
int _shotsPerTarget = 5;
@@ -23,11 +23,12 @@ class SessionProvider extends ChangeNotifier {
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25}) {
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) {
_currentWeaponName = weaponName;
_currentWeaponId = weaponId;
_shotsPerTarget = shots;
_distance = distance;
_sessionDate = date ?? DateTime.now();
_activeSessionId = sessionId;
_currentAnalyses.clear();
_isSessionActive = true;
+119 -52
View File
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'session_provider.dart';
import '../capture/capture_screen.dart';
import '../garage/weapon_list_screen.dart';
class SessionSetupScreen extends StatefulWidget {
const SessionSetupScreen({super.key});
@@ -16,13 +18,16 @@ class SessionSetupScreen extends StatefulWidget {
class _SessionSetupScreenState extends State<SessionSetupScreen> {
final _formKey = GlobalKey<FormState>();
final _weaponController = TextEditingController();
int _shotsPerTarget = 5;
int _distance = 25;
List<Weapon> _availableWeapons = [];
Weapon? _selectedWeapon;
bool _isLoadingWeapons = true;
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
@@ -30,34 +35,63 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
}
Future<void> _loadWeapons() async {
setState(() => _isLoadingWeapons = true);
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
if (mounted) {
setState(() {
_availableWeapons = weapons;
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
_selectedWeapon = _availableWeapons.first;
_updateSettingsForWeapon(_selectedWeapon!);
}
_isLoadingWeapons = false;
});
}
}
@override
void dispose() {
_weaponController.dispose();
super.dispose();
void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
}
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'), // Force le calendrier en français
);
if (picked != null && picked != _selectedDate) {
setState(() {
// On garde aussi l'heure actuelle lors du changement de date
final now = DateTime.now();
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
});
}
}
void _startSession() {
if (_formKey.currentState!.validate()) {
if (_formKey.currentState!.validate() && _selectedWeapon != null) {
final repository = context.read<SessionRepository>();
final sessionProvider = context.read<SessionProvider>();
final sessionId = repository.generateId();
// PASSE-PARTOUT : On envoie la session au provider.
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
sessionProvider.startSession(
_selectedWeapon != null ? _selectedWeapon!.displayName : _weaponController.text,
_selectedWeapon!.displayName,
_shotsPerTarget,
sessionId,
weaponId: _selectedWeapon?.id,
weaponId: _selectedWeapon!.id,
distance: _distance,
date: _selectedDate,
);
Navigator.pushReplacement(
@@ -69,6 +103,9 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
@override
Widget build(BuildContext context) {
// Petit formatage sympa en français (ex: "27 mai 2026")
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
return Scaffold(
appBar: AppBar(
title: const Text('Configuration de la session'),
@@ -83,69 +120,97 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Choix de l\'arme',
'Informations générales',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
if (_availableWeapons.isNotEmpty) ...[
DropdownButtonFormField<Weapon?>(
value: _selectedWeapon,
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
if (_availableWeapons.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
),
child: Column(
children: [
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
const SizedBox(height: 8),
const Text(
'Ton armurerie est vide.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
const Text(
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
).then((_) {
_loadWeapons();
});
},
icon: const Icon(Icons.add),
label: const Text('ALLER À MON ARMURERIE'),
),
],
),
)
else
Column(
children: [
DropdownButtonFormField<Weapon>(
initialValue: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme de l\'armurerie',
labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
items: [
const DropdownMenuItem<Weapon?>(
value: null,
child: Text('Nouvelle arme / Autre'),
),
..._availableWeapons.map((w) => DropdownMenuItem(
items: _availableWeapons.map((w) => DropdownMenuItem(
value: w,
child: Text(w.displayName),
)),
],
)).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedWeapon = value;
if (value != null) {
_weaponController.text = value.displayName;
_shotsPerTarget = value.magazineCapacity;
// Auto-set distance based on weapon type if needed
_distance = (value.type == WeaponType.handgun) ? 25 : 50;
} else {
_weaponController.clear();
}
_updateSettingsForWeapon(value);
});
}
},
),
const SizedBox(height: 16),
const Center(child: Text('OU', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold))),
const SizedBox(height: 16),
],
// Weapon field (manual entry if no weapon selected)
TextFormField(
controller: _weaponController,
enabled: _selectedWeapon == null,
// NOUVEAU CHAMP : Sélecteur de date interactif
InkWell(
onTap: _availableWeapons.isEmpty ? null : _pickDate,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: IgnorePointer(
child: TextFormField(
decoration: InputDecoration(
labelText: 'Nom de l\'arme',
hintText: 'ex: Glock 17, CZ Shadow 2...',
prefixIcon: const Icon(Icons.edit),
labelText: 'Date de la session',
prefixIcon: const Icon(Icons.calendar_today),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez renseigner votre arme';
}
return null;
},
controller: TextEditingController(text: formattedDate),
),
),
),
],
),
const SizedBox(height: 24),
// Distance selector
@@ -161,9 +226,9 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59, // (300-5)/5 = 59 divisions for 5m steps
divisions: 59,
label: '${_distance}m',
onChanged: (value) {
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_distance = value.round();
});
@@ -193,7 +258,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
// Shots per target
const Text(
'Nombre de balles par cible',
'Nombre de balles pour la cible actuelle',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
@@ -203,10 +268,10 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
child: Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 30,
divisions: 29,
max: 50,
divisions: 49,
label: '$_shotsPerTarget',
onChanged: (value) {
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_shotsPerTarget = value.round();
});
@@ -235,7 +300,9 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: _startSession,
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
+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
+253 -63
View File
@@ -1,27 +1,14 @@
/// Service de recadrage d'images.
///
/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder
/// dans un fichier temporaire pour utilisation ultérieure.
library;
import 'dart:isolate'; // AJOUT
import 'dart:io';
import 'dart:math' as math;
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
/// Représente une zone de recadrage normalisée (0.0 à 1.0)
class CropRect {
/// Position X du coin supérieur gauche (0.0 à 1.0)
final double x;
/// Position Y du coin supérieur gauche (0.0 à 1.0)
final double y;
/// Largeur de la zone (0.0 à 1.0)
final double width;
/// Hauteur de la zone (0.0 à 1.0)
final double height;
const CropRect({
@@ -35,47 +22,59 @@ class CropRect {
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
}
/// Service pour recadrer les images
class ImageCropService {
final Uuid _uuid = const Uuid();
// AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable)
class _CropParams {
final String sourcePath;
final double cropX;
final double cropY;
final double cropWidth;
final double cropHeight;
final double rotationDegrees;
final int outputSize;
final String outputPath;
/// Taille de sortie maximale pour les images recadrées
static const int maxOutputSize = 1024;
_CropParams({
required this.sourcePath,
required this.cropX,
required this.cropY,
required this.cropWidth,
required this.cropHeight,
required this.rotationDegrees,
required this.outputSize,
required this.outputPath,
});
}
/// Recadre une image en format carré
///
/// [sourcePath] - Chemin vers l'image source
/// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0)
/// [outputSize] - Taille de sortie en pixels (carré)
///
/// Retourne le chemin vers l'image recadrée dans le dossier temporaire
Future<String> cropToSquare(
String sourcePath,
CropRect cropRect, {
int outputSize = maxOutputSize,
}) async {
// Charger l'image source
final file = File(sourcePath);
final bytes = await file.readAsBytes();
final originalImage = img.decodeImage(bytes);
// AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static)
void _cropIsolateEntry(_CropParams params) {
final file = File(params.sourcePath);
final bytes = file.readAsBytesSync();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) {
throw Exception('Impossible de décoder l\'image: $sourcePath');
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
}
// Calculer les coordonnées en pixels
final srcX = (cropRect.x * originalImage.width).round();
final srcY = (cropRect.y * originalImage.height).round();
final srcWidth = (cropRect.width * originalImage.width).round();
final srcHeight = (cropRect.height * originalImage.height).round();
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
if (params.rotationDegrees != 0.0) {
originalImage = img.copyRotate(
originalImage,
angle: params.rotationDegrees,
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
);
}
// Recadrage
final srcX = (params.cropX * originalImage.width).round();
final srcY = (params.cropY * originalImage.height).round();
final srcWidth = (params.cropWidth * originalImage.width).round();
final srcHeight = (params.cropHeight * originalImage.height).round();
// S'assurer que les dimensions sont valides
final clampedX = srcX.clamp(0, originalImage.width - 1);
final clampedY = srcY.clamp(0, originalImage.height - 1);
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
// Recadrer l'image
img.Image cropped = img.copyCrop(
originalImage,
x: clampedX,
@@ -84,48 +83,207 @@ class ImageCropService {
height: clampedHeight,
);
// Redimensionner à la taille de sortie si nécessaire
if (cropped.width != outputSize || cropped.height != outputSize) {
// Redimensionnement — LINEAR au lieu de CUBIC
if (cropped.width != params.outputSize || cropped.height != params.outputSize) {
cropped = img.copyResize(
cropped,
width: outputSize,
height: outputSize,
interpolation: img.Interpolation.cubic,
width: params.outputSize,
height: params.outputSize,
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
);
}
// Sauvegarder dans le dossier temporaire
final tempDir = await getTemporaryDirectory();
final fileName = 'cropped_${_uuid.v4()}.jpg';
final outputPath = '${tempDir.path}/$fileName';
// Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable)
final outputFile = File(params.outputPath);
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
}
final outputFile = File(outputPath);
await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90));
// 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, {
double rotationDegrees = 0.0,
int outputSize = maxOutputSize,
}) async {
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
final params = _CropParams(
sourcePath: sourcePath,
cropX: cropRect.x,
cropY: cropRect.y,
cropWidth: cropRect.width,
cropHeight: cropRect.height,
rotationDegrees: rotationDegrees,
outputSize: outputSize,
outputPath: outputPath,
);
// OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé
// → le thread UI reste fluide pendant le calcul
await Isolate.run(() => _cropIsolateEntry(params));
return outputPath;
}
/// Calcule la zone de recadrage carrée maximale centrée sur l'image
///
/// [imageWidth] - Largeur de l'image en pixels
/// [imageHeight] - Hauteur de l'image en pixels
///
/// Retourne un CropRect normalisé pour un carré centré
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
final aspectRatio = imageWidth / imageHeight;
if (aspectRatio > 1) {
// Image plus large que haute - centrer horizontalement
final squareWidth = imageHeight / imageWidth;
final x = (1 - squareWidth) / 2;
return CropRect(x: x, y: 0, width: squareWidth, height: 1);
} else if (aspectRatio < 1) {
// Image plus haute que large - centrer verticalement
final squareHeight = imageWidth / imageHeight;
final y = (1 - squareHeight) / 2;
return CropRect(x: 0, y: y, width: 1, height: squareHeight);
} else {
// Déjà carré
return const CropRect(x: 0, y: 0, width: 1, height: 1);
}
}
@@ -147,7 +305,39 @@ class ImageCropService {
}
}
} catch (e) {
// Ignorer les erreurs de nettoyage
// Ignorer les erreurs
}
}
/// Coupe automatiquement une photo brute de l'appareil photo en carré
/// calé sur la position haute du viseur de l'écran.
Future<String> cropRawCameraToSquare(String sourcePath) async {
final file = File(sourcePath);
final bytes = await file.readAsBytes();
final originalImage = img.decodeImage(bytes);
if (originalImage == null) return sourcePath;
final int imgW = originalImage.width;
final int imgH = originalImage.height;
final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round();
final int x = ((imgW - cropSizePixels) / 2).round();
final int y = ((imgH - cropSizePixels) / 2).round();
final squareImage = img.copyCrop(
originalImage,
x: x.clamp(0, imgW - 1),
y: y.clamp(0, imgH - 1),
width: cropSizePixels,
height: cropSizePixels,
);
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg';
await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85));
return outputPath;
}
}
File diff suppressed because it is too large Load Diff
@@ -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();
}
}
+161
View File
@@ -0,0 +1,161 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:sensors_plus/sensors_plus.dart';
/// Statut de parallélisme retourné en temps réel.
enum ParallelismStatus {
/// Pas encore de données capteur disponibles.
unknown,
/// L'appareil est bien parallèle à la cible.
aligned,
/// L'appareil est trop incliné.
misaligned,
}
/// Données de parallélisme calculées à chaque frame capteur.
class ParallelismData {
final ParallelismStatus status;
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
final double pitchDegrees;
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
final double rollDegrees;
const ParallelismData({
required this.status,
required this.pitchDegrees,
required this.rollDegrees,
});
bool get isAligned => status == ParallelismStatus.aligned;
@override
String toString() =>
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
}
/// Service de détection du parallélisme par accéléromètre.
///
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
///
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
/// État actuel = aligned → passe à misaligned si angle > [misalignThreshold]
///
/// Cela crée une "zone de confort" entre les deux seuils où le statut
/// ne change pas — le vert reste vert même si la main tremble légèrement.
///
/// Valeurs par défaut recommandées :
/// alignThreshold = 15° (seuil d'entrée dans le vert — assez souple)
/// misalignThreshold = 22° (seuil de sortie du vert — tolérant au tremblement)
class ParallelismService {
/// Angle max pour passer de misaligned → aligned.
final double alignThreshold;
/// Angle à dépasser pour passer de aligned → misaligned.
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
final double misalignThreshold;
StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast();
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
ParallelismService({
this.alignThreshold = 25.0,
this.misalignThreshold = 32.0,
}) : assert(
misalignThreshold > alignThreshold,
'misalignThreshold doit être supérieur à alignThreshold',
);
Stream<ParallelismData> get stream => _controller.stream;
void start() {
if (_subscription != null) return;
_subscription = accelerometerEventStream(
samplingPeriod: SensorInterval.normalInterval, // ~50 ms
).listen(
_onAccelerometerEvent,
onError: (_) {
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
if (!_controller.isClosed) {
_currentStatus = ParallelismStatus.unknown;
_controller.add(const ParallelismData(
status: ParallelismStatus.unknown,
pitchDegrees: 0,
rollDegrees: 0,
));
}
},
);
}
void stop() {
_subscription?.cancel();
_subscription = null;
_currentStatus = ParallelismStatus.unknown;
}
void dispose() {
stop();
_controller.close();
}
void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return;
final double gx = event.x;
final double gy = event.y;
final double gz = event.z;
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
if (magnitude < 1.0) return; // Données aberrantes
// Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude;
final double nz = gz / magnitude;
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
// Le critère de couleur = le PIRE des deux angles affichés à l'écran.
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert.
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs());
// ── Hystérésis ──────────────────────────────────────────────────────────
// Premier appel : on décide selon alignThreshold uniquement
if (_currentStatus == ParallelismStatus.unknown) {
_currentStatus = worstAngle <= alignThreshold
? ParallelismStatus.aligned
: ParallelismStatus.misaligned;
}
// Déjà misaligned → devient aligned seulement si on passe sous alignThreshold
else if (_currentStatus == ParallelismStatus.misaligned) {
if (worstAngle <= alignThreshold) {
_currentStatus = ParallelismStatus.aligned;
}
}
// Déjà aligned → devient misaligned seulement si on dépasse misalignThreshold
else if (_currentStatus == ParallelismStatus.aligned) {
if (worstAngle > misalignThreshold) {
_currentStatus = ParallelismStatus.misaligned;
}
// Entre alignThreshold et misalignThreshold → on garde le vert, on ne change rien
}
// ────────────────────────────────────────────────────────────────────────
_controller.add(ParallelismData(
status: _currentStatus,
pitchDegrees: pitchDeg,
rollDegrees: rollDeg,
));
}
}
-378
View File
@@ -1,378 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../data/models/target_type.dart';
import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart';
export 'image_processing_service.dart'
show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact;
class TargetDetectionResult {
final double centerX; // Relative (0-1)
final double centerY; // Relative (0-1)
final double radius; // Relative (0-1)
final List<DetectedImpactResult> impacts;
final bool success;
final String? errorMessage;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
required this.impacts,
this.success = true,
this.errorMessage,
});
factory TargetDetectionResult.error(String message) {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
impacts: [],
success: false,
errorMessage: message,
);
}
}
class DetectedImpactResult {
final double x; // Relative (0-1)
final double y; // Relative (0-1)
final double radius; // Absolute pixels
final int suggestedScore;
DetectedImpactResult({
required this.x,
required this.y,
required this.radius,
required this.suggestedScore,
});
}
class TargetDetectionService {
final ImageProcessingService _imageProcessingService;
final OpenCVImpactDetectionService _opencvService;
TargetDetectionService({
ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
try {
// Detect main target
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
// Detect impacts
final impacts = _imageProcessingService.detectImpacts(imagePath);
// Convert impacts to relative coordinates and calculate scores
final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection: $e');
}
}
int _calculateConcentricScore(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones (10 zones)
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0; // Outside target
}
int _calculateSilhouetteScore(
double impactX,
double impactY,
double centerX,
double centerY,
) {
// For silhouettes, scoring is typically based on zones
// Head and center mass = 5, body = 4, lower = 3
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
// Check if within silhouette bounds (approximate)
if (dx > 0.15) return 0; // Too far left/right
// Vertical zones
if (dy < -0.25) return 5; // Head zone (top)
if (dy < 0.0) return 5; // Center mass (upper body)
if (dy < 0.15) return 4; // Body
if (dy < 0.35) return 3; // Lower body
return 0; // Outside target
}
/// Detect only impacts with custom settings (doesn't affect target position)
List<DetectedImpactResult> detectImpactsOnly(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactDetectionSettings settings,
) {
try {
// Detect impacts with custom settings
final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath,
settings,
);
// Convert impacts to relative coordinates and calculate scores
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
int _calculateConcentricScoreWithRings(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
int ringCount,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones based on ringCount
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) {
return 10 - i;
}
}
return 0; // Outside target
}
/// Analyze reference impacts to learn their characteristics
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references,
) {
return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
}
/// Detect impacts based on reference characteristics (calibrated detection)
List<DetectedImpactResult> detectImpactsFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactCharacteristics characteristics, {
double tolerance = 2.0,
}) {
try {
final impacts = _imageProcessingService.detectImpactsFromReferences(
imagePath,
characteristics,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
/// Détecte les impacts en utilisant OpenCV (Hough Circles + Contours)
///
/// Cette méthode utilise les algorithmes OpenCV pour une détection plus robuste:
/// - Transformation de Hough pour détecter les cercles
/// - Analyse de contours avec filtrage par circularité
List<DetectedImpactResult> detectImpactsWithOpenCV(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount, {
OpenCVDetectionSettings? settings,
}) {
try {
final impacts = _opencvService.detectImpacts(
imagePath,
settings: settings ?? const OpenCVDetectionSettings(),
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV: $e');
return [];
}
}
/// Détecte les impacts avec OpenCV en utilisant des références
///
/// Analyse les impacts de référence pour apprendre leurs caractéristiques
/// puis détecte les impacts similaires dans l'image.
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
List<ReferenceImpact> references, {
double tolerance = 2.0,
}) {
try {
// Convertir les références au format OpenCV
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
final impacts = _opencvService.detectFromReferences(
imagePath,
refPoints,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV depuis références: $e');
return [];
}
}
}
+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"))
}
+157 -21
View File
@@ -33,6 +33,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
camera:
dependency: "direct main"
description:
name: camera
sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777"
url: "https://pub.dev"
source: hosted
version: "0.12.0+1"
camera_android_camerax:
dependency: transitive
description:
name: camera_android_camerax
sha256: b5064cf25a2787d122d0bf12e77c7b1033a2b983d0730e3091f770ee376efde5
url: "https://pub.dev"
source: hosted
version: "0.7.2"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d"
url: "https://pub.dev"
source: hosted
version: "0.10.1"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
url: "https://pub.dev"
source: hosted
version: "0.3.5+3"
change_case:
dependency: transitive
description:
@@ -45,10 +85,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
clock:
dependency: transitive
description:
@@ -161,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:
@@ -185,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:
@@ -253,14 +325,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_mlkit_document_scanner:
dependency: "direct main"
description:
name: google_mlkit_document_scanner
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
hooks:
dependency: transitive
description:
@@ -425,26 +489,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -661,6 +725,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
sensors_plus:
dependency: "direct main"
description:
name: sensors_plus
sha256: "8e7fa79b4940442bb595bfc0ee9da4af5a22a0fe6ebacc74998245ee9496a82d"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
sensors_plus_platform_interface:
dependency: transitive
description:
name: sensors_plus_platform_interface
sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f
url: "https://pub.dev"
source: hosted
version: "1.2.0"
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:
@@ -802,6 +898,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -814,10 +918,10 @@ packages:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -830,10 +934,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.9"
version: "0.7.7"
typed_data:
dependency: transitive
description:
@@ -842,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:
@@ -915,5 +1051,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
+7 -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
@@ -36,12 +36,11 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
sensors_plus: ^4.0.2
opencv_dart: ^2.1.0
# Image capture from camera/gallery
image_picker: ^1.2.1
google_mlkit_document_scanner: ^0.2.0
# Local database for history
sqflite: ^2.3.2
@@ -70,9 +69,9 @@ dependencies:
device_info_plus: ^13.1.0
shared_preferences: ^2.5.5
crypto: ^3.0.7
# Machine Learning for YOLOv8
# tflite_flutter: ^0.11.0
camera: ^0.12.0+1
share_plus: ^13.3.0
file_selector: ^1.1.0
dev_dependencies:
flutter_test:
@@ -96,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);
}
-7
View File
@@ -1,7 +0,0 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.approxPolyDP);
print(cv.arcLength);
print(cv.contourArea);
}
-5
View File
@@ -1,5 +0,0 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.findHomography);
}

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