Compare commits

...
Author SHA1 Message Date
qlionbleusam f6f134f2a5 docs(ai-export): state what the training upload sends and drop the anonymity claim 2026-08-29 10:04:09 +02:00
qlionbleusam 56e88c3e06 Merge branch 'fix/ai-export-privacy' 2026-08-29 09:42:25 +02:00
qlionbleusam 0ab9043d82 fix(ai-export): strip photo metadata and minimize the training payload 2026-08-29 09:39:31 +02:00
qlionbleusam eeb857d452 Merge branch 'feature/ajout-armurerie' 2026-08-29 09:21:55 +02:00
qlionbleusam 6dc525c0db fix(session): keep weapon selection valid when returning from the armory 2026-08-29 09:11:20 +02:00
qlionbleusam 96cc487b55 feat(session): add armory shortcut and 50-shot preset 2026-08-29 08:48:41 +02:00
streaper2 8dc6542603 feat: implement AI export service and establish project navigation and theming infrastructure + acces internet
Build & Release Android APK / build-apk (push) Successful in 23m34s
2026-08-27 18:32:59 +02:00
streaper2 e943538133 docs: update changelog with v0.0.7 release notes covering CI/CD, backend improvements, and mobile app features 2026-08-27 08:46:35 +02:00
streaper2 7f1fa2d80b feat(ci): add persistent caching for Gradle, Pub, and Android SDK components 2026-08-26 22:53:40 +02:00
streaper2 a2f7bfc158 fix(ci): remove /data/build completely before clone
Build & Release Android APK / build-apk (push) Successful in 21m20s
2026-08-26 22:51:32 +02:00
streaper2 d0a7700d02 feat: add Gitea workflow to build and release Android APKs via Flutter Docker container
Build & Release Android APK / build-apk (push) Failing after 1s
2026-08-26 22:47:18 +02:00
streaper2 9b52623ebe fix(ci): clone into shared /data/build volume for flutter build
Build & Release Android APK / build-apk (push) Failing after 21m37s
2026-08-26 22:20:17 +02:00
streaper2 ab12e07847 fix(ci): use gitea-act-runner container name for volume
Build & Release Android APK / build-apk (push) Failing after 2s
2026-08-26 22:18:59 +02:00
streaper2 98b9f1cd4c feat: add Gitea workflow to build and release Android APKs on version tags
Build & Release Android APK / build-apk (push) Failing after 2s
2026-08-26 22:16:37 +02:00
streaper2 e889456bfa fix: correction accès backendia avec yunohost
Build & Release Android APK / build-apk (push) Failing after 2m1s
2026-08-26 22:10:03 +02:00
streaper2 c0177b19e3 correction chemin des photos backendia
Deploy Backendia / deploy (push) Successful in 19s
2026-08-26 09:11:20 +02:00
streaper2 99abf60b52 feat: implement automated deployment pipeline and add identity management settings screen with account status synchronization
Deploy Backendia / deploy (push) Successful in 3s
2026-08-26 09:05:30 +02:00
streaper2 6e09ea25dd feat: implement backend server with SQLite logging and target validation middleware
Deploy Backendia / deploy (push) Successful in 40s
2026-08-26 09:01:25 +02:00
streaper2 9a429d476d feat: initialize backend service with Express, SQLite logging, and API utilities
Deploy Backendia / deploy (push) Successful in 5m0s
2026-08-26 08:42:16 +02:00
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
streaper2 774dbfcf40 feat: implement base architecture and core repositories for weapon tracking and target analysis functionality 2026-05-08 20:29:18 +02:00
streaper2 5dd58da51c Merge backend branch and restore functionalities 2026-05-08 08:34:16 +02:00
streaper2 8910a0a87e chore: remove backendia node_modules and build files from git 2026-05-08 08:33:32 +02:00
streaper2 0895b7d5bc Restore UI functionalities and clean up dependencies 2026-05-08 08:32:58 +02:00
streaper2 6892d55c65 feat: add PhotoOverlay component for visualizing and editing impact coordinates 2026-05-07 21:07:55 +02:00
streaper2 cea2fab989 branch backend + ajout du serveur backend, next, et sqlite 2026-04-29 15:45:09 +02:00
120 changed files with 25211 additions and 6634 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:*)"
]
}
}
+81
View File
@@ -0,0 +1,81 @@
name: Build & Release Android APK
on:
push:
tags:
- 'v*'
- 'V*' # Se déclenche quand vous poussez un tag comme v1.0.0, v1.0.1...
workflow_dispatch: # Permet aussi de lancer la compilation manuellement depuis l'interface Gitea
jobs:
build-apk:
# Utilise votre runner hôte avec Docker
runs-on: docker
steps:
- name: 📥 Récupération du code
run: |
echo "📥 Clonage du code source dans /data/build..."
rm -rf /data/build
git config --global --add safe.directory "*"
git clone --depth 1 "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" /data/build
- name: 🔨 Compilation de l'APK (Conteneur Flutter / Android SDK avec Caches)
run: |
echo "🚀 Démarrage de la compilation Flutter avec Caches persistants..."
mkdir -p /data/caches/gradle /data/caches/pub /data/caches/android-ndk /data/caches/android-cmake /data/caches/android-platforms
# Montage des caches persistants : Gradle, Pub, NDK, CMake et Plateformes Android
docker run --rm \
--volumes-from gitea-act-runner \
-v /data/caches/gradle:/root/.gradle \
-v /data/caches/pub:/root/.pub-cache \
-v /data/caches/android-ndk:/opt/android-sdk-linux/ndk \
-v /data/caches/android-cmake:/opt/android-sdk-linux/cmake \
-v /data/caches/android-platforms:/opt/android-sdk-linux/platforms \
-w /data/build \
ghcr.io/cirruslabs/flutter:stable \
sh -c "git config --global --add safe.directory '*' && flutter pub get && flutter build apk --release"
echo "✅ APK généré avec succès dans /data/build/build/app/outputs/flutter-apk/app-release.apk"
- name: 📦 Publication de la Release sur Gitea & Upload de l'APK
run: |
which curl >/dev/null 2>&1 || apk add --no-cache curl
TAG_NAME="${{ github.ref_name }}"
if [ -z "$TAG_NAME" ] || [ "$TAG_NAME" = "main" ]; then
TAG_NAME="build-$(date +'%Y%m%d-%H%M%S')"
fi
APK_PATH="/data/build/build/app/outputs/flutter-apk/app-release.apk"
APK_NAME="bully-impact-${TAG_NAME}.apk"
echo "🚀 Création de la release $TAG_NAME sur Gitea..."
# 1. Création de la Release via l'API REST de Gitea
RELEASE_RESPONSE=$(curl -s -X POST "https://git.kevlar.cloud/api/v1/repos/${{ github.repository }}/releases" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG_NAME}\",
\"name\": \"Version ${TAG_NAME}\",
\"body\": \"Nouvelle version de l'application Bully Impact générée automatiquement par CI/CD.\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then
echo "❌ Erreur lors de la création de la release: $RELEASE_RESPONSE"
exit 1
fi
echo "📦 Upload du fichier APK (Release ID: $RELEASE_ID)..."
# 2. Upload de l'APK en tant qu'asset téléchargeable
curl -s -X POST "https://git.kevlar.cloud/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${APK_NAME}" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @"$APK_PATH"
echo "🎉 Release terminée avec succès ! APK téléchargeable sur Gitea."
+30
View File
@@ -0,0 +1,30 @@
name: Deploy Backendia
on:
push:
branches:
- main
paths:
- 'backendia/**'
- 'docker-compose.prod.yml'
- '.gitea/workflows/deploy.yaml'
workflow_dispatch:
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 !"
+13
View File
@@ -11,6 +11,7 @@
.svn/
.swiftpm/
migrate_working_dir/
.claude/
# IntelliJ related
*.iml
@@ -43,3 +44,15 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Backendia
backendia/node_modules/
backendia/dashboard/node_modules/
backendia/dashboard/.next/
backendia/dashboard/out/
backendia/exports/*.zip
backendia/uploads/images/*
!backendia/uploads/images/.gitkeep
backendia/uploads/data/*
!backendia/uploads/data/.gitkeep
!backendia/uploads/data/database.sqlite
+35
View File
@@ -1,5 +1,40 @@
# Changelog
## [v1.0.3] - 2026-08-27 (Branche `design/newdefault`)
### 🎨 Refonte UI/UX "Tactical & Precision"
- **Système de Thèmes & Couleurs d'Accentuation Personnalisables** : Choix dynamique de la couleur d'accent dans les Paramètres parmi 7 déclinaisons tactiques (Bleu Cobalt, Rouge Cible, Vert Viseur, Orange Ambre, Violet Cyber, Cyan Néon, Or Compétition) avec persistance SharedPreferences.
- **Nouvelle Direction Artistique & Thèmes** : Thème sombre Gunmetal/Slate contrasté pour stand de tir et thème clair technique épuré avec effets Glassmorphism et flous d'arrière-plan.
- **Barre de Navigation Principale** : Intégration de la `NavigationBar` Material 3 avec indicateur pill moderne et icônes thématiques.
- **Dashboard d'Accueil** : En-tête dynamique "BULLY • PRÉCISION", carte interactive de session en cours (badge lumineux, reprise rapide / fin sécurisée), tuiles télémétriques de KPIs avec graphique d'évolution et accès direct aux dernières séances.
- **Armurerie (Arsenal)** : Fiches d'armes techniques avec mise en valeur du calibre, compteurs de chargeurs/coups, puces d'accessoires et **icônes vectorielles dédiées pour chaque type d'arme** (Arme de Poing, Arme d'Épaule, Fusil à Pompe, Airsoft/Airgun).
- **Carnet de Tir (Historique)** : Filtres par puces de type de cible et de date, cartes de séances avec pastilles de score colorées selon la précision.
- **Setup de Session** : Sélecteurs rapides de distance (10m à 200m) et de coups par cible (3 à 30).
- **Réseau & Export Serveur IA (Fix APK Mobile)** : Ajout des permissions `INTERNET` et `ACCESS_NETWORK_STATE` ainsi que `usesCleartextTraffic` dans le manifeste Android principal (`AndroidManifest.xml`) pour débloquer l'envoi vers le serveur IA et les IP locales depuis l'APK installée.
## [v0.0.7] - 2026-08-26
### 🚀 CI/CD & Déploiement Automatique (Gitea Actions)
- **Pipeline de Release Android APK** : Compilation automatique de l'APK de production (`flutter build apk --release`) lors du push d'un tag (`v*`) et publication directe dans les Releases Gitea.
- **Mise en cache persistante** : Sauvegarde des caches Gradle, Pub, Android NDK 28, CMake et SDK Platform pour des temps de build réduits à 1-2 minutes.
- **Pipeline Backendia** : Déploiement continu automatisé du serveur backend et du dashboard lors des modifications sur `main`.
### 🧠 Backend IA & Dashboard Web (Backendia)
- **Architecture Tout-en-un** : API Express et Dashboard Next.js co-hébergés sur le même port sous `https://backendia.kevlar.cloud`.
- **Validation OpenCV** : Certification et détection des anneaux de cibles concentriques.
- **Journalisation & Modération** : Base SQLite avec historique complet des uploads, détection d'appareils, métadonnées et système de bannissement/débannissement de wallets.
- **Dashboard Web** : Galerie de visualisation des photos de tir, éditeur de points/impacts et export global des datasets (ZIP / CSV).
### 🔒 Sécurité & Reverse Proxy (YunoHost)
- **Dashboard Privé** : Accès à l'interface d'administration protégé par le portail SSO YunoHost.
- **API d'Upload Sécurisée** : Filtrage Nginx par en-tête secret `X-API-KEY` autorisant uniquement l'application Android.
### 📱 Application Mobile Flutter
- **Connexion Serveur Officiel** : Configuration par défaut sur `https://backendia.kevlar.cloud` avec transmission automatique de la clé d'API.
- **Identité Anonyme & Stats** : Phrase de récupération de 15 mots, hachage SHA-256 du wallet et synchronisation du statut de modération.
- **Expérience de Tir** : Nouveau flux de calibration, boutons de rotation fine, guidage au premier impact et popup de fin de session avec export IA intégré.
- **Sauvegarde & Partage** : Export/Import JSON complet des sessions, statistiques et armurerie.
## [v0.0.1] - 2026-01-29
### Ajouté
+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:
-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 -1
View File
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Pour Android 12 et inférieur -->
@@ -9,7 +11,8 @@
<application
android:label="bully"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
+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*
+36
View File
@@ -0,0 +1,36 @@
# Dependencies
node_modules/
dashboard/node_modules/
# Next.js build output
dashboard/.next/
dashboard/out/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database (contient des données utilisateurs — jamais dans git)
*.sqlite
*.sqlite-journal
# Uploads and Exports (Ignore content but keep directories)
uploads/images/*
!uploads/images/.gitkeep
uploads/data/*
!uploads/data/.gitkeep
exports/*
!exports/.gitkeep
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# OS files
.DS_Store
Thumbs.db
+31
View File
@@ -0,0 +1,31 @@
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 ./
COPY dashboard/package*.json ./dashboard/
RUN npm install && npm rebuild sqlite3 --build-from-source
RUN cd dashboard && npm install
COPY . .
# Build du Dashboard Next.js
RUN cd dashboard && npm run build
# Dossiers nécessaires
RUN mkdir -p uploads/images uploads/data exports
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
CMD ["node", "server.js"]
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+17
View File
@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '3000',
pathname: '/uploads/**',
},
],
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,104 @@
import { fetchApi } from "@/lib/api";
import { Users, Award, Camera, Clock } from "lucide-react";
interface Contributor {
wallet_hash: string;
photo_count: number;
last_upload: string;
}
export default async function ContributorsPage() {
let contributors: Contributor[] = [];
try {
const response = await fetchApi('/api/contributors');
contributors = response.contributors;
} catch (error) {
console.error("Error fetching contributors:", error);
}
return (
<div className="space-y-8">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Top Contributeurs</h2>
<p className="text-slate-400">Classement des utilisateurs selon leur contribution à l'entraînement de l'IA.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Stats Summary */}
<div className="lg:col-span-1 space-y-6">
<StatCard
title="Total Contributeurs"
value={contributors.length}
icon={<Users className="text-indigo-400" />}
/>
<StatCard
title="Total Photos"
value={contributors.reduce((acc, c) => acc + c.photo_count, 0)}
icon={<Camera className="text-emerald-400" />}
/>
</div>
{/* Table */}
<div className="lg:col-span-3 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-950/50 border-b border-slate-800">
<tr>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest">Rang</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest">Wallet Hash</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest text-center">Photos</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest text-right">Dernier Upload</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{contributors.map((c, index) => (
<tr key={c.wallet_hash} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{index < 3 ? (
<Award className={index === 0 ? "text-yellow-500" : index === 1 ? "text-slate-400" : "text-amber-700"} />
) : (
<span className="text-slate-500 font-mono w-6 text-center">#{index + 1}</span>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="font-mono text-xs text-slate-300">{c.wallet_hash}</span>
</td>
<td className="px-6 py-4 text-center">
<span className="px-2 py-1 bg-indigo-500/10 text-indigo-400 rounded-md text-xs font-bold border border-indigo-500/20">
{c.photo_count}
</span>
</td>
<td className="px-6 py-4 text-right text-xs text-slate-400 flex items-center justify-end gap-2">
<Clock size={14} />
{new Date(c.last_upload).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
{contributors.length === 0 && (
<div className="py-24 text-center text-slate-500">
Aucun contributeur enregistré pour le moment.
</div>
)}
</div>
</div>
</div>
);
}
function StatCard({ title, value, icon }: any) {
return (
<div className="bg-slate-900 border border-slate-800 p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">{title}</p>
<p className="text-3xl font-bold">{value}</p>
</div>
<div className="w-12 h-12 bg-slate-800 rounded-xl flex items-center justify-center shadow-inner">
{icon}
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+74
View File
@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import { LayoutDashboard, Users, ScrollText, Image as ImageIcon } from "lucide-react";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Bully IA Dashboard",
description: "Visualisation des données d'analyse de cibles",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" className="dark">
<body className={`${inter.className} bg-slate-950 text-slate-50 min-h-screen flex`}>
{/* Sidebar */}
<aside className="w-64 border-r border-slate-800 bg-slate-900/50 p-6 flex flex-col gap-8">
<div className="flex items-center gap-3 px-2">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center font-bold">B</div>
<h1 className="text-xl font-bold tracking-tight">Bully IA</h1>
</div>
<nav className="flex flex-col gap-2">
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<LayoutDashboard size={20} />
Tableau de bord
</Link>
<Link
href="/contributors"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<Users size={20} />
Contributeurs
</Link>
<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">
<p className="text-xs text-slate-500 px-2">v1.0.0 Alpha</p>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col">
<header className="h-16 border-b border-slate-800 flex items-center px-8 bg-slate-900/20">
<div className="flex items-center gap-2 text-sm text-slate-400">
<span>Admin</span>
<span>/</span>
<span className="text-slate-200">Dashboard</span>
</div>
</header>
<div className="flex-1 p-8 overflow-auto">
{children}
</div>
</main>
</body>
</html>
);
}
@@ -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}
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { fetchApi } from "@/lib/api";
import DatasetToolbar from "@/components/DatasetToolbar";
import { Calendar, User, Crosshair } from "lucide-react";
import Link from "next/link";
interface PlottingData {
session_id: string;
wallet_hash: string;
timestamp: string;
plotting: {
impacts: any[];
};
}
interface PhotoData {
filename: string;
imageUrl: string;
data: PlottingData | null;
}
export default async function DashboardPage() {
let photos: PhotoData[] = [];
try {
const response = await fetchApi('/api/all-data');
photos = response.data;
} catch (error) {
console.error("Error fetching photos:", error);
}
return (
<div className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Galerie des Sessions</h2>
<p className="text-slate-400">Visualisez et gérez les données d'entraînement collectées.</p>
</div>
</div>
<DatasetToolbar />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{photos.map((photo) => (
<Link
key={photo.filename}
href={`/photo/${photo.filename}`}
className="group relative bg-slate-900 rounded-xl border border-slate-800 overflow-hidden hover:border-indigo-500/50 transition-all hover:shadow-2xl hover:shadow-indigo-500/10"
>
{/* Image Preview */}
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.imageUrl}
alt={photo.filename}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
{/* Badge impacts */}
{photo.data?.plotting?.impacts && (
<div className="absolute top-3 right-3 bg-indigo-600/90 backdrop-blur-md px-2 py-1 rounded-md text-[10px] font-bold flex items-center gap-1">
<Crosshair size={12} />
{photo.data.plotting.impacts.length} IMPACTS
</div>
)}
</div>
{/* Info */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Calendar size={14} className="text-indigo-400" />
{photo.data ? new Date(photo.data.timestamp).toLocaleDateString() : 'Date inconnue'}
</div>
{photo.data ? (
<span className="text-[10px] bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20 font-bold">DATA OK</span>
) : (
<span className="text-[10px] bg-rose-500/10 text-rose-400 px-1.5 py-0.5 rounded border border-rose-500/20 font-bold">NO DATA</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-300 font-mono truncate">
<User size={14} className="text-indigo-400" />
{photo.data?.wallet_hash?.substring(0, 12) || 'Anonyme'}...
</div>
<div className="pt-2 border-t border-slate-800 flex justify-between items-center">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Session ID</span>
<span className="text-xs text-slate-300 font-mono truncate max-w-[120px]">
{photo.data?.session_id || 'N/A'}
</span>
</div>
</div>
</Link>
))}
</div>
{photos.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-slate-500 gap-4 border-2 border-dashed border-slate-800 rounded-3xl">
<Crosshair size={48} className="opacity-20" />
<p>Aucune session d'entraînement trouvée.</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,49 @@
import { fetchApi } from "@/lib/api";
import PhotoEditor from "@/components/PhotoEditor";
import { ChevronLeft, Download } from "lucide-react";
import Link from "next/link";
export default async function PhotoDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id: encodedId } = await params;
const id = decodeURIComponent(encodedId);
let photo = null;
try {
const response = await fetchApi('/api/all-data');
photo = response.data.find((p: any) => p.filename === id);
} catch (error) {
console.error("Error fetching photo detail:", error);
}
if (!photo) {
return <div className="p-12 text-center">Session non trouvée.</div>;
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
>
<ChevronLeft size={20} />
Retour à la galerie
</Link>
<div className="flex gap-3">
<a
href={`/uploads/images/${id}`}
download
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Download size={18} />
Image Originale
</a>
</div>
</div>
<PhotoEditor initialPhoto={photo} />
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Download, Archive, AlertTriangle, Check, RefreshCw } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { useRouter } from "next/navigation";
export default function DatasetToolbar() {
const router = useRouter();
const [isExporting, setIsExporting] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const handleExport = async () => {
setIsExporting(true);
try {
// On ouvre simplement l'URL dans un nouvel onglet pour déclencher le téléchargement
window.open(`${API_BASE_URL}/api/export/zip`, '_blank');
} catch (error) {
console.error("Export error:", error);
} finally {
setIsExporting(false);
}
};
const handleArchive = async () => {
setIsArchiving(true);
try {
const response = await fetch(`${API_BASE_URL}/api/export/archive`, {
method: "POST"
});
if (response.ok) {
setShowArchiveConfirm(false);
router.refresh(); // Rafraîchir la galerie
} else {
alert("Erreur lors de l'archivage");
}
} catch (error) {
console.error("Archive error:", error);
alert("Erreur réseau");
} finally {
setIsArchiving(false);
}
};
return (
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between gap-6 shadow-xl shadow-indigo-500/5">
<div className="space-y-1">
<h3 className="font-bold text-lg flex items-center gap-2">
<RefreshCw size={20} className="text-indigo-400" />
Gestion du Dataset
</h3>
<p className="text-xs text-slate-500">Exportez vos sessions étiquetées en ZIP et archivez-les pour nettoyer la galerie.</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExport}
disabled={isExporting}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 px-5 py-2.5 rounded-xl text-sm font-bold transition-all hover:scale-105 active:scale-95 shadow-lg shadow-indigo-600/20"
>
<Download size={18} />
{isExporting ? "Préparation..." : "Télécharger ZIP"}
</button>
{!showArchiveConfirm ? (
<button
onClick={() => setShowArchiveConfirm(true)}
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-5 py-2.5 rounded-xl text-sm font-bold transition-all border border-slate-700"
>
<Archive size={18} />
Archiver les sessions
</button>
) : (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center gap-2 px-4 py-2 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 text-xs font-bold">
<AlertTriangle size={14} />
Vider la galerie ?
</div>
<button
onClick={handleArchive}
disabled={isArchiving}
className="bg-rose-600 hover:bg-rose-500 p-2.5 rounded-xl transition-colors disabled:opacity-50"
>
<Check size={18} className="text-white" />
</button>
<button
onClick={() => setShowArchiveConfirm(false)}
className="bg-slate-800 hover:bg-slate-700 p-2.5 rounded-xl transition-colors"
>
<Archive size={18} className="text-slate-400" />
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import PhotoOverlay from "./PhotoOverlay";
import { Info, Calendar, User, Smartphone, Cpu, Save, Edit2, X, Trash2, Check, Tag } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { useRouter } from "next/navigation";
interface PhotoEditorProps {
initialPhoto: any;
}
const PREDEFINED_LABELS = ["bullet_hole", "tear", "miss", "rebound", "other"];
export default function PhotoEditor({ initialPhoto }: PhotoEditorProps) {
const router = useRouter();
const [isEditing, setIsEditing] = useState(false);
const [photoData, setPhotoData] = useState(initialPhoto.data);
const [isSaving, setIsSaving] = useState(false);
const handleImpactsChange = (newImpacts: any[]) => {
setPhotoData({
...photoData,
plotting: {
...photoData.plotting,
impacts: newImpacts
}
});
};
const updateLabel = (index: number, label: string) => {
const newImpacts = [...photoData.plotting.impacts];
newImpacts[index] = { ...newImpacts[index], label };
handleImpactsChange(newImpacts);
};
const updateScore = (index: number, score: number) => {
const newImpacts = [...photoData.plotting.impacts];
newImpacts[index] = { ...newImpacts[index], score };
handleImpactsChange(newImpacts);
};
const handleSave = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_BASE_URL}/api/data/${initialPhoto.filename}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(photoData),
});
if (response.ok) {
setIsEditing(false);
router.refresh();
} else {
alert("Erreur lors de la sauvegarde");
}
} catch (error) {
console.error("Save error:", error);
alert("Erreur réseau");
} finally {
setIsSaving(false);
}
};
const impacts = photoData?.plotting?.impacts || [];
return (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Main View */}
<div className="xl:col-span-2 space-y-4">
<div className="flex items-center justify-between bg-slate-900/50 p-4 rounded-xl border border-slate-800">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${isEditing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'}`} />
<span className="text-sm font-medium">
{isEditing ? "Mode Édition Actif" : "Mode Visualisation"}
</span>
</div>
<div className="flex gap-2">
{isEditing ? (
<>
<button
onClick={() => {
setPhotoData(initialPhoto.data);
setIsEditing(false);
}}
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<X size={18} />
Annuler
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
<Save size={18} />
{isSaving ? "Enregistrement..." : "Enregistrer"}
</button>
</>
) : (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Edit2 size={18} />
Modifier les points
</button>
)}
</div>
</div>
<PhotoOverlay
imageUrl={initialPhoto.imageUrl}
impacts={impacts}
targetCorners={photoData?.plotting?.target_corners || []}
isEditing={isEditing}
onImpactsChange={handleImpactsChange}
/>
</div>
{/* Sidebar Info & Labeling */}
<div className="space-y-6">
{/* Labeling Section (Only if editing or has impacts) */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
<div className="flex items-center gap-3">
<Tag className="text-indigo-400" size={20} />
<h3 className="font-bold text-lg">Étiquetage (Impacts)</h3>
</div>
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2 py-0.5 rounded">
{impacts.length} TOTAL
</span>
</div>
<div className="space-y-3 max-h-[400px] overflow-auto pr-2 custom-scrollbar">
{impacts.map((impact: any, index: number) => (
<div key={impact.id} className="bg-slate-950 p-3 rounded-xl border border-slate-800 space-y-3 group">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-500 uppercase">Impact #{impact.id}</span>
{isEditing && (
<button
onClick={(e) => {
const newImpacts = impacts.filter((_: any, i: number) => i !== index);
handleImpactsChange(newImpacts);
}}
className="text-slate-600 hover:text-rose-500 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] text-slate-500 font-bold uppercase">Label</label>
{isEditing ? (
<select
value={impact.label}
onChange={(e) => updateLabel(index, e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
>
{PREDEFINED_LABELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
) : (
<div className="text-xs text-indigo-300 font-medium">{impact.label}</div>
)}
</div>
<div className="space-y-1">
<label className="text-[10px] text-slate-500 font-bold uppercase">Score</label>
{isEditing ? (
<input
type="number"
min="0"
max="10"
value={impact.score}
onChange={(e) => updateScore(index, parseInt(e.target.value) || 0)}
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
/>
) : (
<div className="text-xs text-emerald-400 font-bold">{impact.score}</div>
)}
</div>
</div>
</div>
))}
{impacts.length === 0 && (
<p className="text-center py-8 text-xs text-slate-500 italic">
Aucun impact détecté. {isEditing ? "Cliquez sur l'image pour en ajouter." : ""}
</p>
)}
</div>
</div>
{/* Metadata Card (Read Only) */}
<div className="bg-slate-900/50 border border-slate-800 rounded-2xl p-6 space-y-4 opacity-80">
<div className="flex items-center gap-3 pb-2 border-b border-slate-800">
<Info className="text-slate-400" size={18} />
<h3 className="font-bold text-sm text-slate-300">Métadonnées Session</h3>
</div>
<div className="space-y-3">
<InfoItem icon={<Calendar size={14} />} label="Date" value={photoData ? new Date(photoData.timestamp).toLocaleString() : 'Inconnue'} />
<InfoItem icon={<User size={14} />} label="Contributeur" value={photoData?.wallet_hash || 'Anonyme'} isMono />
<InfoItem icon={<Smartphone size={14} />} label="Appareil" value={photoData?.device_info?.model || 'Inconnu'} />
</div>
</div>
</div>
</div>
);
}
function InfoItem({ icon, label, value, isMono = false }: any) {
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 font-bold uppercase">
{icon}
{label}
</div>
<p className={`text-xs text-slate-300 ${isMono ? 'font-mono truncate' : ''}`}>
{value}
</p>
</div>
);
}
@@ -0,0 +1,183 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface Point {
norm_x: number;
norm_y: number;
}
interface Impact {
id: number;
label: string;
score: number;
coords: Point;
}
interface PhotoOverlayProps {
imageUrl: string;
impacts: Impact[];
targetCorners: Point[];
isEditing?: boolean;
onImpactsChange?: (impacts: Impact[]) => void;
}
export default function PhotoOverlay({
imageUrl,
impacts,
targetCorners,
isEditing = false,
onImpactsChange
}: PhotoOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [dragIndex, setDragIndex] = useState<number | null>(null);
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, []);
const handleImageClick = (e: React.MouseEvent) => {
if (!isEditing || !onImpactsChange || dragIndex !== null) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
// Calculer les coordonnées normalisées
const x = (e.clientX - rect.left) / dimensions.width;
const y = (e.clientY - rect.top) / dimensions.height;
// Créer un nouvel impact
const newImpact: Impact = {
id: impacts.length > 0 ? Math.max(...impacts.map(i => i.id)) + 1 : 1,
label: "bullet_hole",
score: 10,
coords: { norm_x: x, norm_y: y }
};
onImpactsChange([...impacts, newImpact]);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (dragIndex === null || !onImpactsChange) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = (e.clientX - rect.left) / dimensions.width;
const y = (e.clientY - rect.top) / dimensions.height;
const newImpacts = [...impacts];
newImpacts[dragIndex] = {
...newImpacts[dragIndex],
coords: { norm_x: x, norm_y: y }
};
onImpactsChange(newImpacts);
};
const deleteImpact = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
if (!isEditing || !onImpactsChange) return;
const newImpacts = impacts.filter((_, i) => i !== index);
onImpactsChange(newImpacts);
};
return (
<div
ref={containerRef}
className={`relative w-full flex items-center justify-center bg-slate-900 rounded-2xl overflow-hidden border border-slate-800 shadow-2xl min-h-[400px] ${isEditing ? 'cursor-crosshair' : ''}`}
onClick={handleImageClick}
onMouseMove={handleMouseMove}
onMouseUp={() => setDragIndex(null)}
onMouseLeave={() => setDragIndex(null)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt="Target Analysis"
className="max-w-full max-h-[80vh] object-contain select-none"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setDimensions({ width: img.clientWidth, height: img.clientHeight });
}}
/>
{dimensions.width > 0 && (
<svg
className="absolute pointer-events-none"
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
style={{ width: dimensions.width, height: dimensions.height }}
>
{/* Draw Target Box if corners exist */}
{targetCorners && targetCorners.length === 4 && (
<polygon
points={targetCorners.map(c => `${c.norm_x * dimensions.width},${c.norm_y * dimensions.height}`).join(' ')}
fill="rgba(79, 70, 229, 0.15)"
stroke="#6366f1"
strokeWidth="2"
strokeDasharray="4 2"
/>
)}
{/* Draw Impacts */}
{impacts.map((impact, index) => (
<g key={impact.id}>
{/* Glow effect */}
<circle
cx={impact.coords.norm_x * dimensions.width}
cy={impact.coords.norm_y * dimensions.height}
r={isEditing ? "12" : "8"}
fill={isEditing ? "rgba(244, 63, 94, 0.2)" : "rgba(244, 63, 94, 0.3)"}
className={isEditing ? 'pointer-events-auto cursor-move' : ''}
onMouseDown={(e) => {
if (!isEditing) return;
e.stopPropagation();
setDragIndex(index);
}}
onContextMenu={(e) => deleteImpact(e, index)}
/>
{/* Main circle */}
<circle
cx={impact.coords.norm_x * dimensions.width}
cy={impact.coords.norm_y * dimensions.height}
r="5"
fill="#f43f5e"
stroke="white"
strokeWidth="1.5"
/>
{/* Score text */}
<text
x={impact.coords.norm_x * dimensions.width}
y={impact.coords.norm_y * dimensions.height - 10}
className="text-[12px] font-bold fill-white text-center select-none"
textAnchor="middle"
style={{ textShadow: '0 0 4px rgba(0,0,0,0.9), 1px 1px 0 rgba(0,0,0,0.9)' }}
>
{impact.score}
</text>
</g>
))}
</svg>
)}
{isEditing && (
<div className="absolute bottom-4 right-4 bg-slate-950/80 backdrop-blur-md px-3 py-1.5 rounded-full text-[10px] font-bold text-indigo-400 border border-indigo-500/30">
MODE ÉDITION : CLIC POUR AJOUTER DRAG POUR DÉPLACER CLIC DROIT POUR SUPPRIMER
</div>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
const isServer = typeof window === 'undefined';
export const API_BASE_URL = isServer
? `http://127.0.0.1:${process.env.PORT || 3000}`
: '';
export async function fetchApi(endpoint: string) {
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`Failed to fetch ${endpoint}`);
}
return res.json();
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+1
View File
@@ -0,0 +1 @@
# Keep directory structure
+2256
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "backendia",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js",
"dashboard": "cd dashboard && npm run dev -- -p 3001",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@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",
"next": "^16.2.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sharp": "^0.35.3",
"sqlite3": "^6.0.1"
}
}
+857
View File
@@ -0,0 +1,857 @@
const express = require('express');
const multer = require('multer');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
const AdmZip = require('adm-zip');
const TargetValidator = require('./services/target_validator');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Arborescence demandée
const uploadDir = path.join(__dirname, 'uploads');
const imagesDir = path.join(uploadDir, 'images');
const dataDir = path.join(uploadDir, 'data');
const exportsDir = path.join(__dirname, 'exports');
// S'assurer que les dossiers existent
[uploadDir, imagesDir, dataDir, exportsDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
// Initialisation de la base de données SQLite
const dbPath = path.join(dataDir, 'database.sqlite');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Erreur de connexion à SQLite:', err.message);
} else {
console.log('Connecté à la base de données SQLite.');
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
wallet_hash TEXT PRIMARY KEY,
photo_count INTEGER DEFAULT 0,
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
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) {
cb(null, imagesDir); // Les photos vont dans uploads/images/
},
filename: function (req, file, cb) {
// Créer un nom de fichier unique tout en gardant l'extension originale
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 15 * 1024 * 1024 // Limite de taille: 15 MB
},
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
// Accepter si c'est un mimetype image OU si l'extension est celle d'une image
if (file.mimetype.startsWith('image/') || allowedExts.includes(ext) || file.mimetype === 'application/octet-stream') {
cb(null, true);
} else {
console.error(`Fichier rejeté: mimetype=${file.mimetype}, extension=${ext}`);
cb(new Error('Seules les images sont autorisées !'), false);
}
}
});
// Routes de base
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'Le serveur backend IA fonctionne' });
});
// Route pour l'upload de photo + données JSON
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
app.post('/api/upload', upload.single('photo'), 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) {
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 = {};
if (req.body.plotting) {
try {
plottingData = JSON.parse(req.body.plotting);
} catch (e) {
console.error("Erreur parsing JSON:", e);
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`;
const jsonFilePath = path.join(dataDir, jsonFilename);
// 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
if (walletHash) {
db.run(`
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
VALUES (?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(wallet_hash) DO UPDATE SET
photo_count = photo_count + 1,
last_upload = CURRENT_TIMESTAMP
`, [walletHash], function(err) {
if (err) {
console.error("Erreur lors de la mise à jour des stats :", err);
} else {
console.log(`Stats mises à jour pour le wallet_hash ${walletHash.substring(0, 8)}...`);
}
});
}
// 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,
url: `/uploads/images/${req.file.filename}`,
size: req.file.size
},
data: {
filename: jsonFilename
},
target_validation: validation
});
} catch (error) {
console.error('Erreur lors de l\'upload:', error);
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 {
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
}
const photos = files.map(file => ({
filename: file,
url: `/uploads/images/${file}`
}));
res.status(200).json({ photos });
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Route pour récupérer les statistiques d'un wallet_hash + 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, statRow) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
}
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
});
});
});
// Route pour récupérer TOUS les contributeurs
app.get('/api/contributors', (req, res) => {
db.all('SELECT * FROM user_stats ORDER BY photo_count DESC', [], (err, rows) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des contributeurs' });
}
res.json({ contributors: rows });
});
});
// Route pour récupérer toutes les données (Photos + JSON)
app.get('/api/all-data', (req, res) => {
try {
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
}
const allData = files.map(file => {
const baseName = path.parse(file).name;
const jsonPath = path.join(dataDir, `${baseName}.json`);
let plotting = null;
if (fs.existsSync(jsonPath)) {
try {
plotting = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} catch (e) {
console.error(`Erreur lecture JSON pour ${file}`);
}
}
return {
filename: file,
imageUrl: `/uploads/images/${file}`,
data: plotting
};
});
res.status(200).json({ data: allData });
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Route pour mettre à jour les données JSON d'une session
app.put('/api/data/:filename', (req, res) => {
const filename = req.params.filename;
const baseName = path.parse(filename).name;
const jsonPath = path.join(dataDir, `${baseName}.json`);
try {
if (!fs.existsSync(jsonPath)) {
return res.status(404).json({ error: 'Fichier JSON non trouvé' });
}
// On écrase le fichier avec les nouvelles données reçues
const newData = req.body;
fs.writeFileSync(jsonPath, JSON.stringify(newData, null, 2));
console.log(`Données mises à jour pour : ${baseName}.json`);
res.status(200).json({ message: 'Données mises à jour avec succès' });
} catch (error) {
console.error('Erreur lors de la mise à jour du JSON:', error);
res.status(500).json({ error: 'Erreur serveur lors de la mise à jour' });
}
});
// Route pour exporter toutes les données en ZIP
app.get('/api/export/zip', (req, res) => {
try {
const zip = new AdmZip();
// Ajouter les dossiers au ZIP
if (fs.existsSync(imagesDir)) zip.addLocalFolder(imagesDir, 'images');
if (fs.existsSync(dataDir)) zip.addLocalFolder(dataDir, 'data');
const zipFilename = `dataset_export_${Date.now()}.zip`;
const zipPath = path.join(exportsDir, zipFilename);
// Sauvegarder sur le disque
zip.writeZip(zipPath);
// Envoyer le fichier au client
res.download(zipPath, zipFilename, (err) => {
if (err) console.error("Erreur lors de l'envoi du ZIP:", err);
// Optionnel: supprimer le ZIP local après envoi si vous voulez gagner de la place
// fs.unlinkSync(zipPath);
});
} catch (error) {
console.error('Erreur lors de la création du ZIP:', error);
res.status(500).json({ error: 'Erreur lors de la création de l\'archive' });
}
});
// Route pour archiver et vider les dossiers uploads
app.post('/api/export/archive', (req, res) => {
try {
const archiveName = `archive_${new Date().toISOString().replace(/[:.]/g, '-')}`;
const archivePath = path.join(exportsDir, archiveName);
if (!fs.existsSync(archivePath)) {
fs.mkdirSync(archivePath, { recursive: true });
}
// Fonction pour déplacer les fichiers
const moveFiles = (source, destSubDir) => {
const dest = path.join(archivePath, destSubDir);
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
const files = fs.readdirSync(source);
files.forEach(file => {
// Ne pas déplacer la base de données sqlite
if (file === 'database.sqlite') return;
const oldPath = path.join(source, file);
const newPath = path.join(dest, file);
try {
fs.renameSync(oldPath, newPath);
} catch (e) {
// Fallback si renameSync échoue (ex: disque différent)
fs.copyFileSync(oldPath, newPath);
fs.unlinkSync(oldPath);
}
});
};
moveFiles(imagesDir, 'images');
moveFiles(dataDir, 'data');
res.json({ message: 'Données archivées avec succès', archive: archiveName });
} catch (error) {
console.error('Erreur lors de l\'archivage:', error);
res.status(500).json({ error: 'Erreur lors de l\'archivage des données' });
}
});
// Servir les fichiers statiques (pour pouvoir accéder aux photos via URL)
app.use('/uploads/images', express.static(imagesDir));
// Gestion des erreurs
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Le fichier est trop volumineux (limite 15MB)' });
}
}
console.error(err.stack);
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
});
// Intégration du Dashboard Next.js
const dashboardDir = path.join(__dirname, 'dashboard');
const dev = process.env.NODE_ENV !== 'production';
let nextApp;
try {
const next = require('next');
nextApp = next({ dev, dir: dashboardDir });
} catch (e) {
console.warn("Module 'next' non disponible, mode API seule.");
}
async function startServer() {
if (nextApp) {
try {
await nextApp.prepare();
const handle = nextApp.getRequestHandler();
app.use((req, res) => handle(req, res));
console.log("Dashboard Next.js initialisé avec succès.");
} catch (err) {
console.error("Erreur d'initialisation du Dashboard Next.js:", err);
}
}
app.listen(PORT, () => {
console.log(`=================================`);
console.log(`Serveur Backend IA & Dashboard démarré`);
console.log(`Port: ${PORT}`);
console.log(`Dossiers:`);
console.log(` - Images: ${imagesDir}`);
console.log(` - Data : ${dataDir}`);
console.log(` - Export: ${exportsDir}`);
console.log(`=================================`);
});
}
startServer();
+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
View File
+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:
+11 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'core/theme/app_theme.dart';
import 'features/home/home_screen.dart';
import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart';
import 'main_navigation_holder.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class BullyApp extends StatelessWidget {
@@ -8,12 +9,14 @@ class BullyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: 'Bully - Analyse de Cibles',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
theme: themeProvider.lightTheme,
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.themeMode,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
@@ -23,8 +26,9 @@ class BullyApp extends StatelessWidget {
Locale('fr', 'FR'), // Français
],
locale: const Locale('fr', 'FR'), // Force l'interface en français
home: const HomeScreen(),
home: MainNavigationHolder(key: mainNavKey),
);
},
);
}
}
+4 -1
View File
@@ -3,11 +3,14 @@ class AppConstants {
// Database
static const String databaseName = 'bully_targets.db';
static const int databaseVersion = 1;
static const int databaseVersion = 6;
// Tables
static const String sessionsTable = 'sessions';
static const String targetAnalysesTable = 'target_analyses';
static const String shotsTable = 'shots';
static const String weaponsTable = 'weapons';
static const String maintenanceTable = 'weapon_maintenance';
// Image processing
static const double minImpactRadius = 2.0;
+377 -42
View File
@@ -1,90 +1,425 @@
import 'package:flutter/material.dart';
/// Définition des couleurs d'accent disponibles pour la personnalisation.
class AppAccentColor {
final String id;
final String name;
final Color color;
final Color lightColor;
final Color darkColor;
final IconData icon;
const AppAccentColor({
required this.id,
required this.name,
required this.color,
required this.lightColor,
required this.darkColor,
required this.icon,
});
static const AppAccentColor blue = AppAccentColor(
id: 'blue',
name: 'Bleu Cobalt',
color: Color(0xFF2563EB),
lightColor: Color(0xFF60A5FA),
darkColor: Color(0xFF1D4ED8),
icon: Icons.lens,
);
static const AppAccentColor red = AppAccentColor(
id: 'red',
name: 'Rouge Cible',
color: Color(0xFFDC2626),
lightColor: Color(0xFFF87171),
darkColor: Color(0xFFB91C1C),
icon: Icons.lens,
);
static const AppAccentColor green = AppAccentColor(
id: 'green',
name: 'Vert Viseur',
color: Color(0xFF10B981),
lightColor: Color(0xFF34D399),
darkColor: Color(0xFF059669),
icon: Icons.lens,
);
static const AppAccentColor orange = AppAccentColor(
id: 'orange',
name: 'Orange Ambre',
color: Color(0xFFFF6D00),
lightColor: Color(0xFFFF9E40),
darkColor: Color(0xFFD84315),
icon: Icons.lens,
);
static const AppAccentColor purple = AppAccentColor(
id: 'purple',
name: 'Violet Cyber',
color: Color(0xFF8B5CF6),
lightColor: Color(0xFFA78BFA),
darkColor: Color(0xFF6D28D9),
icon: Icons.lens,
);
static const AppAccentColor cyan = AppAccentColor(
id: 'cyan',
name: 'Cyan Néon',
color: Color(0xFF06B6D4),
lightColor: Color(0xFF67E8F9),
darkColor: Color(0xFF0E7490),
icon: Icons.lens,
);
static const AppAccentColor gold = AppAccentColor(
id: 'gold',
name: 'Or Compétition',
color: Color(0xFFF59E0B),
lightColor: Color(0xFFFBBF24),
darkColor: Color(0xFFD97706),
icon: Icons.lens,
);
static const List<AppAccentColor> allAccents = [
blue,
red,
green,
orange,
purple,
cyan,
gold,
];
static AppAccentColor fromId(String? id) {
return allAccents.firstWhere(
(a) => a.id == id,
orElse: () => blue,
);
}
}
/// Système de design et thème de l'application Bully.
class AppTheme {
AppTheme._();
static const Color primaryColor = Color(0xFF1E88E5);
static const Color secondaryColor = Color(0xFF43A047);
static const Color errorColor = Color(0xFFE53935);
static const Color warningColor = Color(0xFFFFA726);
static const Color successColor = Color(0xFF66BB6A);
// Accents par défaut (compatibilité statique)
static const Color primaryColor = Color(0xFF2563EB);
static const Color primaryLight = Color(0xFF60A5FA);
static const Color primaryDark = Color(0xFF1D4ED8);
static const Color backgroundColor = Color(0xFFF5F5F5);
static const Color surfaceColor = Colors.white;
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
static const Color secondaryColor = Color(0xFF10B981);
static const Color secondaryDark = Color(0xFF059669);
// Impact colors for visualization
static const Color impactColor = Color(0xFFFF5722);
static const Color accentBlue = Color(0xFF0EA5E9);
static const Color accentGold = Color(0xFFFFB300);
static const Color errorColor = Color(0xFFEF4444);
static const Color warningColor = Color(0xFFF59E0B);
static const Color successColor = Color(0xFF10B981);
// Palette Thème Sombre (Standard Stand de Tir)
static const Color darkBackground = Color(0xFF0D1219);
static const Color darkSurface = Color(0xFF161E28);
static const Color darkSurfaceElevated = Color(0xFF1E2836);
static const Color darkSurfaceVariant = Color(0xFF253243);
static const Color darkBorder = Color(0xFF2C3B4E);
static const Color darkBorderLight = Color(0xFF3B4D65);
static const Color darkTextPrimary = Color(0xFFF1F5F9);
static const Color darkTextSecondary = Color(0xFF94A3B8);
static const Color darkTextMuted = Color(0xFF64748B);
// Palette Thème Clair
static const Color lightBackground = Color(0xFFF8FAFC);
static const Color lightSurface = Color(0xFFFFFFFF);
static const Color lightSurfaceElevated = Color(0xFFFFFFFF);
static const Color lightSurfaceVariant = Color(0xFFF1F5F9);
static const Color lightBorder = Color(0xFFE2E8F0);
static const Color lightTextPrimary = Color(0xFF0F172A);
static const Color lightTextSecondary = Color(0xFF64748B);
static const Color lightTextMuted = Color(0xFF94A3B8);
// Compatibilité ascendante
static const Color backgroundColor = lightBackground;
static const Color surfaceColor = lightSurface;
static const Color textPrimary = lightTextPrimary;
static const Color textSecondary = lightTextSecondary;
// Couleurs des impacts pour l'overlay
static const Color impactColor = Color(0xFFFF3D00);
static const Color impactOutlineColor = Color(0xFFFFFFFF);
static const Color groupingCenterColor = Color(0xFF2196F3);
static const Color groupingCircleColor = Color(0x4D2196F3);
static const Color groupingCenterColor = Color(0xFF00E5FF);
static const Color groupingCircleColor = Color(0x4D00E5FF);
// Score zone colors
// Couleurs des zones de score cibles concentriques
static const List<Color> zoneColors = [
Color(0xFFFFEB3B), // Zone 10 - Gold
Color(0xFFFFEB3B), // Zone 9
Color(0xFFFFB300), // Zone 10 - Or
Color(0xFFFFCA28), // Zone 9
Color(0xFFFF5722), // Zone 8
Color(0xFFFF5722), // Zone 7
Color(0xFF2196F3), // Zone 6
Color(0xFF2196F3), // Zone 5
Color(0xFF4CAF50), // Zone 4
Color(0xFF4CAF50), // Zone 3
Color(0xFFFF7043), // Zone 7
Color(0xFF29B6F6), // Zone 6
Color(0xFF4FC3F7), // Zone 5
Color(0xFF66BB6A), // Zone 4
Color(0xFF81C784), // Zone 3
Color(0xFFFFFFFF), // Zone 2
Color(0xFFFFFFFF), // Zone 1
Color(0xFFE0E0E0), // Zone 1
];
static ThemeData get lightTheme {
static ThemeData get lightTheme => buildLightTheme(AppAccentColor.blue);
static ThemeData get darkTheme => buildDarkTheme(AppAccentColor.blue);
static ThemeData buildLightTheme(AppAccentColor accent) {
final activePrimary = accent.color;
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.light,
colorScheme: ColorScheme.light(
primary: activePrimary,
secondary: secondaryColor,
surface: lightSurface,
error: errorColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: lightTextPrimary,
onError: Colors.white,
outline: lightBorder,
),
scaffoldBackgroundColor: backgroundColor,
scaffoldBackgroundColor: lightBackground,
cardColor: lightSurface,
appBarTheme: const AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: primaryColor,
foregroundColor: Colors.white,
backgroundColor: lightSurface,
foregroundColor: lightTextPrimary,
surfaceTintColor: Colors.transparent,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: lightTextPrimary,
letterSpacing: 0.3,
),
),
cardTheme: CardThemeData(
elevation: 2,
elevation: 0,
color: lightSurface,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: lightBorder, width: 1),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: primaryColor,
elevation: 0,
backgroundColor: activePrimary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: lightTextPrimary,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
side: const BorderSide(color: lightBorder, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: lightSurfaceVariant,
selectedColor: activePrimary.withValues(alpha: 0.15),
labelStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: lightTextPrimary,
),
secondaryLabelStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: activePrimary,
),
side: const BorderSide(color: lightBorder, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: lightSurfaceVariant,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: lightBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: lightBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: activePrimary, width: 1.8),
),
labelStyle: const TextStyle(color: lightTextSecondary),
hintStyle: const TextStyle(color: lightTextMuted),
),
dividerTheme: const DividerThemeData(
color: lightBorder,
thickness: 1,
space: 24,
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: activePrimary,
foregroundColor: Colors.white,
elevation: 3,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
);
}
static ThemeData get darkTheme {
static ThemeData buildDarkTheme(AppAccentColor accent) {
final activePrimary = accent.color;
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: activePrimary,
secondary: secondaryColor,
surface: darkSurface,
error: errorColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: darkTextPrimary,
onError: Colors.white,
outline: darkBorder,
),
scaffoldBackgroundColor: darkBackground,
cardColor: darkSurface,
appBarTheme: const AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: darkBackground,
foregroundColor: darkTextPrimary,
surfaceTintColor: Colors.transparent,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: darkTextPrimary,
letterSpacing: 0.3,
),
),
cardTheme: CardThemeData(
elevation: 2,
elevation: 0,
color: darkSurface,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: darkBorder, width: 1),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: activePrimary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: darkTextPrimary,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
side: const BorderSide(color: darkBorder, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: darkSurfaceElevated,
selectedColor: activePrimary.withValues(alpha: 0.2),
labelStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: darkTextPrimary,
),
secondaryLabelStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: activePrimary,
),
side: const BorderSide(color: darkBorder, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: darkSurfaceElevated,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: darkBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: darkBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: activePrimary, width: 1.8),
),
labelStyle: const TextStyle(color: darkTextSecondary),
hintStyle: const TextStyle(color: darkTextMuted),
),
dividerTheme: const DividerThemeData(
color: darkBorder,
thickness: 1,
space: 24,
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: activePrimary,
foregroundColor: Colors.white,
elevation: 3,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
);
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_theme.dart';
class ThemeProvider with ChangeNotifier {
static const String _themeModeKey = 'user_theme_mode';
static const String _accentKey = 'user_accent_color';
ThemeMode _themeMode = ThemeMode.system;
AppAccentColor _accent = AppAccentColor.blue;
ThemeMode get themeMode => _themeMode;
AppAccentColor get currentAccent => _accent;
Color get primaryColor => _accent.color;
ThemeData get lightTheme => AppTheme.buildLightTheme(_accent);
ThemeData get darkTheme => AppTheme.buildDarkTheme(_accent);
ThemeProvider() {
loadSettings();
}
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final modeIndex = prefs.getInt(_themeModeKey);
final accentId = prefs.getString(_accentKey);
if (modeIndex != null) {
_themeMode = ThemeMode.values[modeIndex];
}
if (accentId != null) {
_accent = AppAccentColor.fromId(accentId);
}
notifyListeners();
}
Future<void> setThemeMode(ThemeMode mode) async {
if (_themeMode == mode) return;
_themeMode = mode;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_themeModeKey, mode.index);
}
Future<void> setAccent(AppAccentColor accent) async {
if (_accent.id == accent.id) return;
_accent = accent;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accentKey, accent.id);
}
String get themeModeName {
switch (_themeMode) {
case ThemeMode.system:
return 'Automatique';
case ThemeMode.light:
return 'Clair';
case ThemeMode.dark:
return 'Sombre';
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Conteneur avec effet de verre givré (Glassmorphism),
/// bordures translucides fines et reflets subtils pour casser l'aspect standard Flutter.
class GlassContainer extends StatelessWidget {
final Widget child;
final double borderRadius;
final double blur;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry margin;
final Color? customBackgroundColor;
final Color? borderColor;
final Color? glowColor;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const GlassContainer({
super.key,
required this.child,
this.borderRadius = 18.0,
this.blur = 12.0,
this.padding = const EdgeInsets.all(16.0),
this.margin = EdgeInsets.zero,
this.customBackgroundColor,
this.borderColor,
this.glowColor,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final defaultBg = isDark
? const Color(0xFF141C26).withValues(alpha: 0.72)
: Colors.white.withValues(alpha: 0.82);
final defaultBorder = isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08);
final resolvedBorderColor = borderColor ?? defaultBorder;
final resolvedBg = customBackgroundColor ?? defaultBg;
Widget content = Container(
padding: padding,
decoration: BoxDecoration(
color: resolvedBg,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: resolvedBorderColor, width: 1.2),
boxShadow: [
if (glowColor != null)
BoxShadow(
color: glowColor!.withValues(alpha: isDark ? 0.25 : 0.15),
blurRadius: 18,
spreadRadius: -2,
offset: const Offset(0, 4),
)
else
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.28 : 0.04),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: child,
);
if (blur > 0) {
content = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: content,
),
);
} else {
content = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: content,
);
}
if (margin != EdgeInsets.zero) {
content = Padding(padding: margin, child: content);
}
if (onTap != null || onLongPress != null) {
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(borderRadius),
child: InkWell(
borderRadius: BorderRadius.circular(borderRadius),
onTap: onTap,
onLongPress: onLongPress,
splashColor: (glowColor ?? AppTheme.primaryColor).withValues(alpha: 0.15),
highlightColor: (glowColor ?? AppTheme.primaryColor).withValues(alpha: 0.08),
child: content,
),
);
}
return content;
}
}
+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'),
),
],
),
);
}
}
+335
View File
@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import '../../data/models/weapon.dart';
/// Widget affichant une icône vectorielle stylisée et fidèle pour chaque type d'arme.
class WeaponTypeIcon extends StatelessWidget {
final WeaponType type;
final Color? color;
final double size;
const WeaponTypeIcon({
super.key,
required this.type,
this.color,
this.size = 24,
});
@override
Widget build(BuildContext context) {
final effectiveColor = color ?? Theme.of(context).colorScheme.primary;
return CustomPaint(
size: Size(size, size),
painter: _WeaponTypePainter(
type: type,
color: effectiveColor,
),
);
}
}
class _WeaponTypePainter extends CustomPainter {
final WeaponType type;
final Color color;
_WeaponTypePainter({
required this.type,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final fillPaint = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
final strokePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
canvas.save();
// Normalisation sur une grille 32x32 pour un rendu très précis
final scale = size.width / 32.0;
canvas.scale(scale, scale);
switch (type) {
case WeaponType.handgun:
_drawGlock(canvas, fillPaint, strokePaint);
break;
case WeaponType.rifle:
_drawKalashnikov(canvas, fillPaint, strokePaint);
break;
case WeaponType.shotgun:
_drawPumpShotgun(canvas, fillPaint, strokePaint);
break;
case WeaponType.airgun:
_drawAirgun(canvas, fillPaint, strokePaint);
break;
}
canvas.restore();
}
/// Silhouette fidèle d'un Glock : culasse carrée "boxy", pontet angulaire et poignée ergonomique
void _drawGlock(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Culasse rectangulaire Glock (Slide)
final slidePath = Path();
slidePath.moveTo(4, 9);
slidePath.lineTo(26.5, 9); // dessus de culasse
slidePath.lineTo(27.5, 10); // chanfrein nez avant
slidePath.lineTo(27.5, 14.5); // bouche du canon
slidePath.lineTo(4, 14.5); // bas de culasse
slidePath.lineTo(3.5, 13); // chanfrein arrière
slidePath.lineTo(3.5, 10);
slidePath.close();
canvas.drawPath(slidePath, fillPaint);
// Organes de visée Glock
// Cran de mire arrière carré
canvas.drawRect(const Rect.fromLTWH(5, 7.2, 2.5, 1.8), fillPaint);
// Guidon avant
canvas.drawRect(const Rect.fromLTWH(24.5, 7.2, 1.8, 1.8), fillPaint);
// 2. Carcasse inférieure & Poignée (Frame & Grip)
final framePath = Path();
framePath.moveTo(4, 14.5);
framePath.lineTo(26, 14.5); // rail picatinny avant
framePath.lineTo(25.5, 16.5);
framePath.lineTo(17.5, 16.5); // bas du rail avant pontet
framePath.lineTo(17.5, 19.5); // face avant du pontet carré Glock
framePath.lineTo(12.5, 20.5); // bas du pontet
framePath.lineTo(12, 17.5); // jonction détente / poignée avant
framePath.lineTo(9.5, 27); // poignée avant avec empreinte
framePath.lineTo(4.5, 27); // talon de chargeur / magwell
framePath.lineTo(8, 16.5); // dos de poignée / beavertail Glock
framePath.lineTo(4, 15.5);
framePath.close();
canvas.drawPath(framePath, fillPaint);
// 3. Queue de détente Safe Action
final triggerPath = Path();
triggerPath.moveTo(15.5, 15.5);
triggerPath.quadraticBezierTo(14, 17.5, 15.2, 19);
canvas.drawPath(triggerPath, strokePaint..strokeWidth = 1.3);
// 4. Stries de préhension arrière (Serrations)
for (double i = 6; i <= 10; i += 1.5) {
canvas.drawLine(
Offset(i, 10),
Offset(i, 13.5),
Paint()
..color = Colors.black.withValues(alpha: 0.35)
..strokeWidth = 0.8,
);
}
}
/// Silhouette fidèle d'une Kalashnikov (AK-47 / AKM) : chargeur banane, emprunt de gaz et guidon haut
void _drawKalashnikov(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Crosse arrière en bois / épaule
final stockPath = Path();
stockPath.moveTo(1.5, 13);
stockPath.lineTo(8, 14.5);
stockPath.lineTo(8, 18);
stockPath.lineTo(1.5, 20.5);
stockPath.close();
canvas.drawPath(stockPath, fillPaint);
// 2. Boîtier de culasse (Receiver) & Capot supérieur
final receiverPath = Path();
receiverPath.moveTo(8, 13.5);
receiverPath.lineTo(17, 13.5);
receiverPath.lineTo(17, 18);
receiverPath.lineTo(8, 18);
receiverPath.close();
canvas.drawPath(receiverPath, fillPaint);
// 3. Garde-main & Tube d'emprunt de gaz supérieur
final handguardPath = Path();
handguardPath.moveTo(17, 13.5);
handguardPath.lineTo(24, 13.5);
handguardPath.lineTo(24, 17);
handguardPath.lineTo(17, 17);
handguardPath.close();
canvas.drawPath(handguardPath, fillPaint);
// 4. Canon fin avant & Bloc guidon Kalash
final barrelPath = Path();
barrelPath.moveTo(24, 14.5);
barrelPath.lineTo(30.5, 14.5);
barrelPath.lineTo(30.5, 16);
barrelPath.lineTo(24, 16);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Bloc guidon triangulaire surélevé avant
final frontSightPath = Path();
frontSightPath.moveTo(28, 14.5);
frontSightPath.lineTo(29, 11);
frontSightPath.lineTo(30, 14.5);
frontSightPath.close();
canvas.drawPath(frontSightPath, fillPaint);
// Hausse arrière au dessus du boîtier
canvas.drawRect(const Rect.fromLTWH(16.5, 12, 2, 1.5), fillPaint);
// 5. Chargeur courbé "banane" emblématique (30 coups)
final magPath = Path();
magPath.moveTo(14, 18);
magPath.lineTo(16.5, 18);
magPath.quadraticBezierTo(17.5, 22.5, 15, 26);
magPath.lineTo(12, 25.5);
magPath.quadraticBezierTo(14, 22, 13.5, 18);
magPath.close();
canvas.drawPath(magPath, fillPaint);
// 6. Poignée pistolet séparée
final gripPath = Path();
gripPath.moveTo(8.5, 18);
gripPath.lineTo(10.5, 18);
gripPath.lineTo(9.5, 23.5);
gripPath.lineTo(7.5, 23);
gripPath.close();
canvas.drawPath(gripPath, fillPaint);
// 7. Pontet et détente
final triggerGuard = Path();
triggerGuard.moveTo(11, 18);
triggerGuard.quadraticBezierTo(11.5, 20, 13, 19.5);
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
}
/// Silhouette fidèle d'un Fusil à Pompe (Pump Action Shotgun : type Remington 870)
void _drawPumpShotgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Crosse arrière traditionnelle ergonomique
final stockPath = Path();
stockPath.moveTo(1, 14.5);
stockPath.lineTo(9, 15.5);
stockPath.lineTo(9, 19.5);
stockPath.lineTo(6.5, 20); // poignée semi-pistolet
stockPath.lineTo(1, 21.5);
stockPath.close();
canvas.drawPath(stockPath, fillPaint);
// 2. Boîtier de culasse récepteur (Receiver)
final receiverPath = Path();
receiverPath.moveTo(9, 14);
receiverPath.lineTo(16.5, 14);
receiverPath.lineTo(16.5, 19);
receiverPath.lineTo(9, 19);
receiverPath.close();
canvas.drawPath(receiverPath, fillPaint);
// 3. Canon long calibre 12 (Barrel)
final barrelPath = Path();
barrelPath.moveTo(16.5, 14);
barrelPath.lineTo(31, 14);
barrelPath.lineTo(31, 15.5);
barrelPath.lineTo(16.5, 15.5);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Grain d'orge / mire avant sur le canon
canvas.drawCircle(const Offset(30, 13.2), 0.8, fillPaint);
// 4. Tube magasin inférieur sous le canon
final magTubePath = Path();
magTubePath.moveTo(16.5, 16);
magTubePath.lineTo(27, 16);
magTubePath.lineTo(27, 17.5);
magTubePath.lineTo(16.5, 17.5);
magTubePath.close();
canvas.drawPath(magTubePath, fillPaint);
// Bague de fixation canon / tube magasin
canvas.drawRect(const Rect.fromLTWH(26, 14, 1.2, 4), fillPaint);
// 5. Pompe mobile striée d'actionnement (Forend / Pump)
final pumpPath = Path();
pumpPath.addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(18, 15.5, 6.5, 3.5),
const Radius.circular(0.8),
));
canvas.drawPath(pumpPath, fillPaint);
// Stries sur la pompe
for (double x = 19.5; x <= 23.5; x += 1.3) {
canvas.drawLine(
Offset(x, 16),
Offset(x, 18.5),
Paint()
..color = Colors.black.withValues(alpha: 0.4)
..strokeWidth = 0.6,
);
}
// 6. Pontet et détente
final triggerGuard = Path();
triggerGuard.moveTo(10, 19);
triggerGuard.quadraticBezierTo(11.5, 21.5, 13.5, 20);
triggerGuard.lineTo(13.5, 19);
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
}
/// Silhouette fidèle d'une arme à air comprimé / cible plomb (Airgun)
void _drawAirgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// Pistolet match 10m / Airgun olympique avec réservoir d'air cylindrique
// 1. Canon supérieur et boîtier
final barrelPath = Path();
barrelPath.moveTo(4, 12);
barrelPath.lineTo(28, 12);
barrelPath.lineTo(28, 14);
barrelPath.lineTo(4, 14);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Organes de visée match : dioptre arrière et tunnel de guidon avant
canvas.drawRect(const Rect.fromLTWH(4.5, 9.5, 3, 2.5), fillPaint);
canvas.drawRect(const Rect.fromLTWH(25, 10, 2.5, 2), fillPaint);
// 2. Bonbonne d'air comprimé cylindrique inférieure (Cylinder)
final tankPath = Path();
tankPath.addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(10, 14.5, 15, 3),
const Radius.circular(1.5),
));
canvas.drawPath(tankPath, fillPaint);
// Manomètre avant
canvas.drawCircle(const Offset(25, 16), 1.2, strokePaint..strokeWidth = 0.8);
// 3. Poignée anatomique bois de tir sportif avec repose-paume
final gripPath = Path();
gripPath.moveTo(6, 14);
gripPath.lineTo(10, 14);
gripPath.lineTo(9.5, 23.5);
gripPath.lineTo(3.5, 22.5);
gripPath.close();
canvas.drawPath(gripPath, fillPaint);
// Tablette repose-paume réglable
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(2, 22.5, 9, 2.2),
const Radius.circular(0.8),
),
fillPaint,
);
// 4. Pontet match
final guardPath = Path();
guardPath.moveTo(10, 14.5);
guardPath.quadraticBezierTo(12, 18.5, 9.5, 18.5);
canvas.drawPath(guardPath, strokePaint..strokeWidth = 1.0);
}
@override
bool shouldRepaint(covariant _WeaponTypePainter oldDelegate) {
return oldDelegate.type != type || oldDelegate.color != color;
}
}
+458 -83
View File
@@ -1,12 +1,21 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/session.dart';
import '../models/target_analysis.dart';
import '../models/shot.dart';
import '../models/weapon.dart';
import '../models/maintenance.dart';
import '../../core/constants/app_constants.dart';
class DatabaseHelper {
static DatabaseHelper? _instance;
static Database? _database;
// On met en cache le Future d'initialisation (et non la Database résolue)
// pour éviter qu'un démarrage concurrent (les 4 onglets de l'IndexedStack
// interrogent la base en même temps) ne lance plusieurs _initDatabase() en
// parallèle. Sur une base fraîche, cela dédoublait onCreate et rendait la
// toute première écriture peu fiable.
static Future<Database>? _initFuture;
DatabaseHelper._internal();
@@ -16,7 +25,9 @@ class DatabaseHelper {
}
Future<Database> get database async {
_database ??= await _initDatabase();
if (_database != null) return _database!;
_initFuture ??= _initDatabase();
_database = await _initFuture!;
return _database!;
}
@@ -36,6 +47,19 @@ class DatabaseHelper {
await db.execute('''
CREATE TABLE ${AppConstants.sessionsTable} (
id TEXT PRIMARY KEY,
weapon TEXT NOT NULL,
weapon_id TEXT,
max_shots_per_target INTEGER NOT NULL,
created_at TEXT NOT NULL,
notes TEXT,
distance INTEGER DEFAULT 25
)
''');
await db.execute('''
CREATE TABLE ${AppConstants.targetAnalysesTable} (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
target_type TEXT NOT NULL,
image_path TEXT NOT NULL,
total_score INTEGER NOT NULL,
@@ -46,7 +70,8 @@ class DatabaseHelper {
notes TEXT,
target_center_x REAL,
target_center_y REAL,
target_radius REAL
target_radius REAL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
)
''');
@@ -56,48 +81,262 @@ class DatabaseHelper {
x REAL NOT NULL,
y REAL NOT NULL,
score INTEGER NOT NULL,
session_id TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
analysis_id TEXT NOT NULL,
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
)
''');
await db.execute('''
CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id)
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
''');
await db.execute('''
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
''');
await db.execute('''
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
''');
await db.execute('''
CREATE TABLE ${AppConstants.weaponsTable} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
caliber TEXT NOT NULL,
magazine_count INTEGER NOT NULL,
magazine_capacity INTEGER NOT NULL,
notes TEXT,
created_at TEXT NOT NULL,
optic TEXT,
silencer TEXT,
trigger TEXT,
custom_name TEXT
)
''');
await db.execute('''
CREATE TABLE ${AppConstants.maintenanceTable} (
id TEXT PRIMARY KEY,
weapon_id TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT NOT NULL,
date TEXT NOT NULL,
rounds_since_last INTEGER,
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
// Handle future database migrations here
if (oldVersion < 2) {
// Migration from single-target sessions to multi-target sessions
// 1. Rename existing sessions table to a temporary name
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} RENAME TO sessions_old');
// 2. Create the new sessions table
await db.execute('''
CREATE TABLE ${AppConstants.sessionsTable} (
id TEXT PRIMARY KEY,
weapon TEXT NOT NULL,
max_shots_per_target INTEGER NOT NULL,
created_at TEXT NOT NULL,
notes TEXT
)
''');
// 3. Create the new target_analyses table
await db.execute('''
CREATE TABLE ${AppConstants.targetAnalysesTable} (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
target_type TEXT NOT NULL,
image_path TEXT NOT NULL,
total_score INTEGER NOT NULL,
grouping_diameter REAL,
grouping_center_x REAL,
grouping_center_y REAL,
created_at TEXT NOT NULL,
notes TEXT,
target_center_x REAL,
target_center_y REAL,
target_radius REAL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
)
''');
// 4. Update shots table (rename session_id to analysis_id)
await db.execute('ALTER TABLE ${AppConstants.shotsTable} RENAME TO shots_old');
await db.execute('''
CREATE TABLE ${AppConstants.shotsTable} (
id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
score INTEGER NOT NULL,
analysis_id TEXT NOT NULL,
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
)
''');
// 5. Migrate data
// For each old session, create one session and one target_analysis
final oldSessions = await db.query('sessions_old');
for (final old in oldSessions) {
final sessionId = old['id'] as String;
final analysisId = 'analysis_$sessionId'; // Create a unique ID for the analysis
// Insert into new sessions
await db.insert(AppConstants.sessionsTable, {
'id': sessionId,
'weapon': 'Inconnue',
'max_shots_per_target': 5,
'created_at': old['created_at'],
'notes': old['notes'],
});
// Insert into target_analyses
await db.insert(AppConstants.targetAnalysesTable, {
'id': analysisId,
'session_id': sessionId,
'target_type': old['target_type'],
'image_path': old['image_path'],
'total_score': old['total_score'],
'grouping_diameter': old['grouping_diameter'],
'grouping_center_x': old['grouping_center_x'],
'grouping_center_y': old['grouping_center_y'],
'created_at': old['created_at'],
'notes': null,
'target_center_x': old['target_center_x'],
'target_center_y': old['target_center_y'],
'target_radius': old['target_radius'],
});
// Migrate shots from shots_old to shots
final oldShots = await db.query('shots_old', where: 'session_id = ?', whereArgs: [sessionId]);
for (final shot in oldShots) {
await db.insert(AppConstants.shotsTable, {
'id': shot['id'],
'x': shot['x'],
'y': shot['y'],
'score': shot['score'],
'analysis_id': analysisId,
});
}
}
// 6. Cleanup
await db.execute('DROP TABLE sessions_old');
await db.execute('DROP TABLE shots_old');
// 7. Re-create indexes
await db.execute('''
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
''');
await db.execute('''
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
''');
await db.execute('''
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
''');
}
if (oldVersion < 3) {
// Add weapons table
await db.execute('''
CREATE TABLE ${AppConstants.weaponsTable} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
caliber TEXT NOT NULL,
magazine_count INTEGER NOT NULL,
magazine_capacity INTEGER NOT NULL,
notes TEXT,
created_at TEXT NOT NULL
)
''');
// Add weapon_id to sessions table
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN weapon_id TEXT');
}
if (oldVersion < 4) {
// Add maintenance table
await db.execute('''
CREATE TABLE ${AppConstants.maintenanceTable} (
id TEXT PRIMARY KEY,
weapon_id TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT NOT NULL,
date TEXT NOT NULL,
rounds_since_last INTEGER,
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
)
''');
}
if (oldVersion < 5) {
// Add new weapon options columns
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN optic TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN silencer TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN "trigger" TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN custom_name TEXT');
}
if (oldVersion < 6) {
// Add distance column to sessions
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN distance INTEGER DEFAULT 25');
}
}
// Session operations
Future<int> insertSession(Session session) async {
final db = await database;
return await db.transaction((txn) async {
// INTERCEPTION ET FIX DE LA DATE ICI :
// On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet
final Map<String, dynamic> sessionMap = Map.from(session.toMap());
sessionMap['created_at'] = session.createdAt.toIso8601String();
await txn.insert(
AppConstants.sessionsTable,
session.toMap(),
sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct !
conflictAlgorithm: ConflictAlgorithm.replace,
);
for (final shot in session.shots) {
// Ensure shot has correct session_id
final shotWithSessionId = shot.copyWith(sessionId: session.id);
await txn.insert(
AppConstants.shotsTable,
shotWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
for (final analysis in session.analyses) {
await insertTargetAnalysisTx(txn, analysis, session.id);
}
return 1;
});
}
Future<void> insertTargetAnalysis(TargetAnalysis analysis, String sessionId) async {
final db = await database;
await db.transaction((txn) async {
await insertTargetAnalysisTx(txn, analysis, sessionId);
});
}
Future<void> insertTargetAnalysisTx(Transaction txn, TargetAnalysis analysis, String sessionId) async {
final analysisWithSessionId = analysis.copyWith(sessionId: sessionId);
await txn.insert(
AppConstants.targetAnalysesTable,
analysisWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
for (final shot in analysis.shots) {
final shotWithAnalysisId = shot.copyWith(analysisId: analysis.id);
await txn.insert(
AppConstants.shotsTable,
shotWithAnalysisId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
Future<Session?> getSession(String id) async {
final db = await database;
final sessionMaps = await db.query(
@@ -108,104 +347,104 @@ class DatabaseHelper {
if (sessionMaps.isEmpty) return null;
final shotMaps = await db.query(
AppConstants.shotsTable,
final analysisMaps = await db.query(
AppConstants.targetAnalysesTable,
where: 'session_id = ?',
whereArgs: [id],
);
final analyses = <TargetAnalysis>[];
for (final analysisMap in analysisMaps) {
final analysisId = analysisMap['id'] as String;
final shotMaps = await db.query(
AppConstants.shotsTable,
where: 'analysis_id = ?',
whereArgs: [analysisId],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
return Session.fromMap(sessionMaps.first, shots);
analyses.add(TargetAnalysis.fromMap(analysisMap, shots));
}
return Session.fromMap(sessionMaps.first, analyses);
}
Future<List<Session>> getAllSessions({
String? targetType,
int? limit,
int? offset,
}) async {
final db = await database;
String? whereClause;
List<dynamic>? whereArgs;
if (targetType != null) {
whereClause = 'target_type = ?';
whereArgs = [targetType];
}
final sessionMaps = await db.query(
AppConstants.sessionsTable,
where: whereClause,
whereArgs: whereArgs,
orderBy: 'created_at DESC',
limit: limit,
offset: offset,
);
if (sessionMaps.isEmpty) return [];
// First, check if there are orphaned shots (with empty session_id)
// and only one session - if so, assign them to that session
if (sessionMaps.length == 1) {
final orphanedShots = await db.query(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [''],
// 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,
);
if (orphanedShots.isNotEmpty) {
await db.update(
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
final shotMaps = await _queryIn(
db,
AppConstants.shotsTable,
{'session_id': sessionMaps.first['id']},
where: 'session_id = ?',
whereArgs: [''],
'analysis_id',
analysisIds,
);
}
final shotsByAnalysis = <String, List<Shot>>{};
for (final map in shotMaps) {
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
.add(Shot.fromMap(map));
}
final sessions = <Session>[];
for (final sessionMap in sessionMaps) {
final sessionId = sessionMap['id'] as String;
final shotMaps = await db.query(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [sessionId],
final analysesBySession = <String, List<TargetAnalysis>>{};
for (final map in analysisMaps) {
final analysis = TargetAnalysis.fromMap(
map,
shotsByAnalysis[map['id'] as String] ?? [],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
sessions.add(Session.fromMap(sessionMap, shots));
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
}
return sessions;
return sessionMaps
.map((m) =>
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
.toList();
}
Future<int> updateSession(Session session) async {
final db = await database;
return await db.transaction((txn) async {
await txn.update(
AppConstants.sessionsTable,
session.toMap(),
where: 'id = ?',
whereArgs: [session.id],
);
// Delete existing shots and insert new ones
await txn.delete(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [session.id],
);
for (final shot in session.shots) {
// Ensure shot has correct session_id
final shotWithSessionId = shot.copyWith(sessionId: session.id);
await txn.insert(
AppConstants.shotsTable,
shotWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
/// 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 1;
});
return results;
}
Future<int> deleteSession(String id) async {
@@ -229,12 +468,12 @@ class DatabaseHelper {
) ?? 0;
final avgScore = (await db.rawQuery(
'SELECT AVG(total_score) as avg FROM ${AppConstants.sessionsTable}',
'SELECT AVG(total_score) as avg FROM ${AppConstants.targetAnalysesTable}',
)).first['avg'] as double? ?? 0.0;
final bestScore = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}',
'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}',
),
) ?? 0;
@@ -246,9 +485,145 @@ class DatabaseHelper {
};
}
// Weapon operations
Future<int> insertWeapon(Weapon weapon) async {
final db = await database;
return await db.insert(
AppConstants.weaponsTable,
weapon.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<Weapon>> getAllWeapons() async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(AppConstants.weaponsTable, orderBy: 'name ASC');
return List.generate(maps.length, (i) => Weapon.fromMap(maps[i]));
}
Future<Weapon?> getWeapon(String id) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
AppConstants.weaponsTable,
where: 'id = ?',
whereArgs: [id],
);
if (maps.isEmpty) return null;
return Weapon.fromMap(maps.first);
}
Future<int> updateWeapon(Weapon weapon) async {
final db = await database;
// Start a transaction to ensure both updates succeed
return await db.transaction((txn) async {
// 1. Update weapon in armory
final count = await txn.update(
AppConstants.weaponsTable,
weapon.toMap(),
where: 'id = ?',
whereArgs: [weapon.id],
);
// 2. Propagate name change to all sessions linked to this weapon
await txn.update(
AppConstants.sessionsTable,
{'weapon': weapon.displayName},
where: 'weapon_id = ?',
whereArgs: [weapon.id],
);
return count;
});
}
Future<int> deleteWeapon(String id) async {
final db = await database;
return await db.transaction((txn) async {
// 1. Unlink sessions from this weapon but KEEP the name (gravé à jamais)
await txn.update(
AppConstants.sessionsTable,
{'weapon_id': null},
where: 'weapon_id = ?',
whereArgs: [id],
);
// 2. Delete the weapon record
return await txn.delete(
AppConstants.weaponsTable,
where: 'id = ?',
whereArgs: [id],
);
});
}
// Weapon statistics & Maintenance
Future<int> getRoundsFiredForWeapon(String weaponId) async {
final db = await database;
final result = await db.rawQuery('''
SELECT COUNT(s.id) as count
FROM ${AppConstants.shotsTable} s
JOIN ${AppConstants.targetAnalysesTable} ta ON s.analysis_id = ta.id
JOIN ${AppConstants.sessionsTable} sess ON ta.session_id = sess.id
WHERE sess.weapon_id = ?
''', [weaponId]);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> getSessionCountForWeapon(String weaponId) async {
final db = await database;
final result = await db.rawQuery('''
SELECT COUNT(id) as count
FROM ${AppConstants.sessionsTable}
WHERE weapon_id = ?
''', [weaponId]);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> insertMaintenance(MaintenanceEntry entry) async {
final db = await database;
return await db.insert(
AppConstants.maintenanceTable,
entry.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// 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(
AppConstants.maintenanceTable,
where: 'weapon_id = ?',
orderBy: 'date DESC',
whereArgs: [weaponId],
);
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
}
Future<int> deleteMaintenance(String id) async {
final db = await database;
return await db.delete(
AppConstants.maintenanceTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
_initFuture = null;
}
}
+57
View File
@@ -0,0 +1,57 @@
enum MaintenanceType {
cleaning('Nettoyage'),
repair('Réparation'),
inspection('Inspection'),
upgrade('Amélioration'),
other('Autre');
final String displayName;
const MaintenanceType(this.displayName);
static MaintenanceType fromString(String value) {
return MaintenanceType.values.firstWhere(
(type) => type.name == value,
orElse: () => MaintenanceType.other,
);
}
}
class MaintenanceEntry {
final String id;
final String weaponId;
final MaintenanceType type;
final String description;
final DateTime date;
final int? roundsSinceLastMaintenance;
MaintenanceEntry({
required this.id,
required this.weaponId,
required this.type,
required this.description,
required this.date,
this.roundsSinceLastMaintenance,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'weapon_id': weaponId,
'type': type.name,
'description': description,
'date': date.toIso8601String(),
'rounds_since_last': roundsSinceLastMaintenance,
};
}
factory MaintenanceEntry.fromMap(Map<String, dynamic> map) {
return MaintenanceEntry(
id: map['id'] as String,
weaponId: map['weapon_id'] as String,
type: MaintenanceType.fromString(map['type'] as String),
description: map['description'] as String,
date: DateTime.parse(map['date'] as String),
roundsSinceLastMaintenance: map['rounds_since_last'] as int?,
);
}
}
+38 -67
View File
@@ -1,112 +1,83 @@
import 'shot.dart';
import 'target_type.dart';
import 'target_analysis.dart';
class Session {
final String id;
final TargetType targetType;
final String imagePath;
final List<Shot> shots;
final int totalScore;
final double? groupingDiameter;
final double? groupingCenterX;
final double? groupingCenterY;
final String weapon;
final String? weaponId;
final int maxShotsPerTarget;
final List<TargetAnalysis> analyses;
final DateTime createdAt;
final String? notes;
// Target detection data
final double? targetCenterX;
final double? targetCenterY;
final double? targetRadius;
final int distance; // Shooting distance in meters
Session({
required this.id,
required this.targetType,
required this.imagePath,
required this.shots,
required this.totalScore,
this.groupingDiameter,
this.groupingCenterX,
this.groupingCenterY,
required this.weapon,
this.weaponId,
required this.maxShotsPerTarget,
required this.analyses,
required this.createdAt,
this.notes,
this.targetCenterX,
this.targetCenterY,
this.targetRadius,
this.distance = 25, // Default distance
});
int get shotCount => shots.length;
int get targetCount => analyses.length;
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
int get totalShots => analyses.fold(0, (sum, a) => sum + a.shotCount);
int get totalScore => analyses.fold(0, (sum, a) => sum + a.totalScore);
double get averageScore => analyses.isEmpty ? 0.0 : totalScore / analyses.length;
Session copyWith({
String? id,
TargetType? targetType,
String? imagePath,
List<Shot>? shots,
int? totalScore,
double? groupingDiameter,
double? groupingCenterX,
double? groupingCenterY,
String? weapon,
String? weaponId,
int? maxShotsPerTarget,
List<TargetAnalysis>? analyses,
DateTime? createdAt,
String? notes,
double? targetCenterX,
double? targetCenterY,
double? targetRadius,
int? distance,
}) {
return Session(
id: id ?? this.id,
targetType: targetType ?? this.targetType,
imagePath: imagePath ?? this.imagePath,
shots: shots ?? this.shots,
totalScore: totalScore ?? this.totalScore,
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
weapon: weapon ?? this.weapon,
weaponId: weaponId ?? this.weaponId,
maxShotsPerTarget: maxShotsPerTarget ?? this.maxShotsPerTarget,
analyses: analyses ?? this.analyses,
createdAt: createdAt ?? this.createdAt,
notes: notes ?? this.notes,
targetCenterX: targetCenterX ?? this.targetCenterX,
targetCenterY: targetCenterY ?? this.targetCenterY,
targetRadius: targetRadius ?? this.targetRadius,
distance: distance ?? this.distance,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'target_type': targetType.name,
'image_path': imagePath,
'total_score': totalScore,
'grouping_diameter': groupingDiameter,
'grouping_center_x': groupingCenterX,
'grouping_center_y': groupingCenterY,
'weapon': weapon,
'weapon_id': weaponId,
'max_shots_per_target': maxShotsPerTarget,
'created_at': createdAt.toIso8601String(),
'notes': notes,
'target_center_x': targetCenterX,
'target_center_y': targetCenterY,
'target_radius': targetRadius,
'distance': distance,
};
}
factory Session.fromMap(Map<String, dynamic> map, List<Shot> shots) {
factory Session.fromMap(Map<String, dynamic> map, List<TargetAnalysis> analyses) {
return Session(
id: map['id'] as String,
targetType: TargetType.fromString(map['target_type'] as String),
imagePath: map['image_path'] as String,
shots: shots,
totalScore: map['total_score'] as int,
groupingDiameter: map['grouping_diameter'] as double?,
groupingCenterX: map['grouping_center_x'] as double?,
groupingCenterY: map['grouping_center_y'] as double?,
weapon: map['weapon'] as String? ?? 'Inconnue',
weaponId: map['weapon_id'] as String?,
maxShotsPerTarget: map['max_shots_per_target'] as int? ?? 5,
analyses: analyses,
createdAt: DateTime.parse(map['created_at'] as String),
notes: map['notes'] as String?,
targetCenterX: map['target_center_x'] as double?,
targetCenterY: map['target_center_y'] as double?,
targetRadius: map['target_radius'] as double?,
distance: map['distance'] as int? ?? 25,
);
}
@override
String toString() {
return 'Session(id: $id, targetType: $targetType, shots: ${shots.length}, totalScore: $totalScore, createdAt: $createdAt)';
return 'Session(id: $id, weapon: $weapon, distance: ${distance}m, targets: ${analyses.length}, createdAt: $createdAt)';
}
}
+9 -9
View File
@@ -3,14 +3,14 @@ class Shot {
final double x; // Relative position (0.0 - 1.0)
final double y; // Relative position (0.0 - 1.0)
final int score;
final String sessionId;
final String analysisId;
Shot({
required this.id,
required this.x,
required this.y,
required this.score,
required this.sessionId,
required this.analysisId,
});
Shot copyWith({
@@ -18,14 +18,14 @@ class Shot {
double? x,
double? y,
int? score,
String? sessionId,
String? analysisId,
}) {
return Shot(
id: id ?? this.id,
x: x ?? this.x,
y: y ?? this.y,
score: score ?? this.score,
sessionId: sessionId ?? this.sessionId,
analysisId: analysisId ?? this.analysisId,
);
}
@@ -35,7 +35,7 @@ class Shot {
'x': x,
'y': y,
'score': score,
'session_id': sessionId,
'analysis_id': analysisId,
};
}
@@ -45,13 +45,13 @@ class Shot {
x: (map['x'] as num).toDouble(),
y: (map['y'] as num).toDouble(),
score: map['score'] as int,
sessionId: map['session_id'] as String,
analysisId: map['analysis_id'] as String? ?? map['session_id'] as String? ?? '',
);
}
@override
String toString() {
return 'Shot(id: $id, x: $x, y: $y, score: $score, sessionId: $sessionId)';
return 'Shot(id: $id, x: $x, y: $y, score: $score, analysisId: $analysisId)';
}
@override
@@ -62,11 +62,11 @@ class Shot {
other.x == x &&
other.y == y &&
other.score == score &&
other.sessionId == sessionId;
other.analysisId == analysisId;
}
@override
int get hashCode {
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ sessionId.hashCode;
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ analysisId.hashCode;
}
}
+113
View File
@@ -0,0 +1,113 @@
import 'shot.dart';
import 'target_type.dart';
class TargetAnalysis {
final String id;
final String sessionId;
final TargetType targetType;
final String imagePath;
final List<Shot> shots;
final int totalScore;
final double? groupingDiameter;
final double? groupingCenterX;
final double? groupingCenterY;
final DateTime createdAt;
final String? notes;
// Target detection data
final double? targetCenterX;
final double? targetCenterY;
final double? targetRadius;
TargetAnalysis({
required this.id,
required this.sessionId,
required this.targetType,
required this.imagePath,
required this.shots,
required this.totalScore,
this.groupingDiameter,
this.groupingCenterX,
this.groupingCenterY,
required this.createdAt,
this.notes,
this.targetCenterX,
this.targetCenterY,
this.targetRadius,
});
int get shotCount => shots.length;
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
TargetAnalysis copyWith({
String? id,
String? sessionId,
TargetType? targetType,
String? imagePath,
List<Shot>? shots,
int? totalScore,
double? groupingDiameter,
double? groupingCenterX,
double? groupingCenterY,
DateTime? createdAt,
String? notes,
double? targetCenterX,
double? targetCenterY,
double? targetRadius,
}) {
return TargetAnalysis(
id: id ?? this.id,
sessionId: sessionId ?? this.sessionId,
targetType: targetType ?? this.targetType,
imagePath: imagePath ?? this.imagePath,
shots: shots ?? this.shots,
totalScore: totalScore ?? this.totalScore,
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
createdAt: createdAt ?? this.createdAt,
notes: notes ?? this.notes,
targetCenterX: targetCenterX ?? this.targetCenterX,
targetCenterY: targetCenterY ?? this.targetCenterY,
targetRadius: targetRadius ?? this.targetRadius,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'session_id': sessionId,
'target_type': targetType.name,
'image_path': imagePath,
'total_score': totalScore,
'grouping_diameter': groupingDiameter,
'grouping_center_x': groupingCenterX,
'grouping_center_y': groupingCenterY,
'created_at': createdAt.toIso8601String(),
'notes': notes,
'target_center_x': targetCenterX,
'target_center_y': targetCenterY,
'target_radius': targetRadius,
};
}
factory TargetAnalysis.fromMap(Map<String, dynamic> map, List<Shot> shots) {
return TargetAnalysis(
id: map['id'] as String,
sessionId: map['session_id'] as String,
targetType: TargetType.fromString(map['target_type'] as String),
imagePath: map['image_path'] as String,
shots: shots,
totalScore: map['total_score'] as int,
groupingDiameter: map['grouping_diameter'] as double?,
groupingCenterX: map['grouping_center_x'] as double?,
groupingCenterY: map['grouping_center_y'] as double?,
createdAt: DateTime.parse(map['created_at'] as String),
notes: map['notes'] as String?,
targetCenterX: map['target_center_x'] as double?,
targetCenterY: map['target_center_y'] as double?,
targetRadius: map['target_radius'] as double?,
);
}
}
+117
View File
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
enum WeaponType {
handgun('Arme de Poing', Icons.shield),
rifle('Arme d\'Épaule', Icons.filter_center_focus),
shotgun('Fusil à Pompe', Icons.splitscreen),
airgun('Airsoft / Airgun', Icons.air);
final String displayName;
final IconData defaultIcon;
const WeaponType(this.displayName, this.defaultIcon);
static WeaponType fromString(String value) {
return WeaponType.values.firstWhere(
(type) => type.name == value,
orElse: () => WeaponType.handgun,
);
}
}
class Weapon {
final String id;
final String name;
final WeaponType type;
final String caliber;
final int magazineCount;
final int magazineCapacity;
final String? notes;
final DateTime createdAt;
// New options
final String? optic;
final String? silencer;
final String? trigger;
final String? customName;
String get displayName => (customName != null && customName!.isNotEmpty) ? customName! : name;
Weapon({
required this.id,
required this.name,
required this.type,
required this.caliber,
required this.magazineCount,
required this.magazineCapacity,
this.notes,
required this.createdAt,
this.optic,
this.silencer,
this.trigger,
this.customName,
});
Weapon copyWith({
String? id,
String? name,
WeaponType? type,
String? caliber,
int? magazineCount,
int? magazineCapacity,
String? notes,
DateTime? createdAt,
String? optic,
String? silencer,
String? trigger,
String? customName,
}) {
return Weapon(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
caliber: caliber ?? this.caliber,
magazineCount: magazineCount ?? this.magazineCount,
magazineCapacity: magazineCapacity ?? this.magazineCapacity,
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
optic: optic ?? this.optic,
silencer: silencer ?? this.silencer,
trigger: trigger ?? this.trigger,
customName: customName ?? this.customName,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'type': type.name,
'caliber': caliber,
'magazine_count': magazineCount,
'magazine_capacity': magazineCapacity,
'notes': notes,
'created_at': createdAt.toIso8601String(),
'optic': optic,
'silencer': silencer,
'trigger': trigger,
'custom_name': customName,
};
}
factory Weapon.fromMap(Map<String, dynamic> map) {
return Weapon(
id: map['id'] as String,
name: map['name'] as String,
type: WeaponType.fromString(map['type'] as String),
caliber: map['caliber'] as String,
magazineCount: map['magazine_count'] as int,
magazineCapacity: map['magazine_capacity'] as int,
notes: map['notes'] as String?,
createdAt: DateTime.parse(map['created_at'] as String),
optic: map['optic'] as String?,
silencer: map['silencer'] as String?,
trigger: map['trigger'] as String?,
customName: map['custom_name'] as String?,
);
}
}
+155 -15
View File
@@ -4,8 +4,10 @@ import 'package:path/path.dart' as path;
import 'package:uuid/uuid.dart';
import '../database/database_helper.dart';
import '../models/session.dart';
import '../models/target_analysis.dart';
import '../models/shot.dart';
import '../models/target_type.dart';
import '../models/weapon.dart';
import '../models/maintenance.dart';
class SessionRepository {
final DatabaseHelper _databaseHelper;
@@ -18,8 +20,38 @@ class SessionRepository {
_uuid = uuid ?? const Uuid();
Future<Session> createSession({
required TargetType targetType,
String? id,
required String weapon,
String? weaponId,
required int maxShotsPerTarget,
required List<TargetAnalysis> analyses,
String? notes,
int distance = 25,
DateTime? date,
}) async {
final session = Session(
id: id ?? _uuid.v4(),
weapon: weapon,
weaponId: weaponId,
maxShotsPerTarget: maxShotsPerTarget,
analyses: analyses,
createdAt: date ?? DateTime.now(),
notes: notes,
distance: distance,
);
await _databaseHelper.insertSession(session);
return session;
}
Future<void> addAnalysisToSession(String sessionId, TargetAnalysis analysis) async {
await _databaseHelper.insertTargetAnalysis(analysis, sessionId);
}
Future<TargetAnalysis> prepareTargetAnalysis({
required String imagePath,
required String sessionId,
required dynamic targetType, // TargetType
required List<Shot> shots,
required int totalScore,
double? groupingDiameter,
@@ -33,8 +65,9 @@ class SessionRepository {
// Copy image to app documents directory
final savedImagePath = await _saveImage(imagePath);
final session = Session(
return TargetAnalysis(
id: _uuid.v4(),
sessionId: sessionId,
targetType: targetType,
imagePath: savedImagePath,
shots: shots,
@@ -48,9 +81,6 @@ class SessionRepository {
targetCenterY: targetCenterY,
targetRadius: targetRadius,
);
await _databaseHelper.insertSession(session);
return session;
}
Future<String> _saveImage(String sourcePath) async {
@@ -75,30 +105,25 @@ class SessionRepository {
}
Future<List<Session>> getAllSessions({
TargetType? targetType,
int? limit,
int? offset,
}) async {
return await _databaseHelper.getAllSessions(
targetType: targetType?.name,
limit: limit,
offset: offset,
);
}
Future<void> updateSession(Session session) async {
await _databaseHelper.updateSession(session);
}
Future<void> deleteSession(String id) async {
final session = await getSession(id);
if (session != null) {
// Delete the image file
final imageFile = File(session.imagePath);
for (final analysis in session.analyses) {
final imageFile = File(analysis.imagePath);
if (await imageFile.exists()) {
await imageFile.delete();
}
}
}
await _databaseHelper.deleteSession(id);
}
@@ -106,7 +131,122 @@ class SessionRepository {
return await _databaseHelper.getStatistics();
}
String generateShotId() {
/// 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();
}
// Weapon Repository methods
Future<Weapon> addWeapon({
required String name,
required WeaponType type,
required String caliber,
required int magazineCount,
required int magazineCapacity,
String? notes,
String? optic,
String? silencer,
String? trigger,
String? customName,
}) async {
final weapon = Weapon(
id: _uuid.v4(),
name: name,
type: type,
caliber: caliber,
magazineCount: magazineCount,
magazineCapacity: magazineCapacity,
notes: notes,
createdAt: DateTime.now(),
optic: optic,
silencer: silencer,
trigger: trigger,
customName: customName,
);
await _databaseHelper.insertWeapon(weapon);
return weapon;
}
Future<List<Weapon>> getWeapons() async {
return await _databaseHelper.getAllWeapons();
}
/// 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);
}
Future<void> deleteWeapon(String id) async {
await _databaseHelper.deleteWeapon(id);
}
Future<int> getRoundsFiredForWeapon(String weaponId) async {
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
}
Future<int> getSessionCountForWeapon(String weaponId) async {
return await _databaseHelper.getSessionCountForWeapon(weaponId);
}
Future<void> addMaintenanceEntry({
required String weaponId,
required MaintenanceType type,
required String description,
int? roundsSinceLast,
DateTime? date,
}) async {
final entry = MaintenanceEntry(
id: _uuid.v4(),
weaponId: weaponId,
type: type,
description: description,
date: date ?? DateTime.now(),
roundsSinceLastMaintenance: roundsSinceLast,
);
await _databaseHelper.insertMaintenance(entry);
}
Future<List<MaintenanceEntry>> getMaintenanceHistory(String weaponId) async {
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
}
/// 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);
}
}
+153 -505
View File
@@ -1,55 +1,47 @@
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
///
/// Gère le workflow complet d'analyse : chargement d'image, détection de cible,
/// gestion des impacts (manuels et automatiques), calcul des scores,
/// analyse de groupement et sauvegarde des sessions.
/// Gère le workflow complet d'analyse : chargement d'image, gestion des
/// impacts placés manuellement, calcul des scores, analyse de groupement
/// et sauvegarde des sessions.
library;
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/shot.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../../services/distortion_correction_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/ai_export_service.dart';
enum AnalysisState { initial, loading, success, error }
class AnalysisProvider extends ChangeNotifier {
final TargetDetectionService _detectionService;
final ScoreCalculatorService _scoreCalculatorService;
final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository;
final DistortionCorrectionService _distortionService;
final OpenCVTargetService _opencvTargetService;
final Uuid _uuid = const Uuid();
AnalysisProvider({
required TargetDetectionService detectionService,
required ScoreCalculatorService scoreCalculatorService,
required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository,
DistortionCorrectionService? distortionService,
OpenCVTargetService? opencvTargetService,
}) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService,
}) : _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;
@@ -67,20 +59,12 @@ class AnalysisProvider extends ChangeNotifier {
// Grouping results
GroupingResult? _groupingResult;
// Reference-based detection
List<Shot> _referenceImpacts = [];
ImpactCharacteristics? _learnedCharacteristics;
// Distortion correction
bool _distortionCorrectionEnabled = false;
DistortionParameters? _distortionParams;
String? _correctedImagePath;
// Getters
AnalysisState get state => _state;
String? get errorMessage => _errorMessage;
String? get imagePath => _imagePath;
TargetType? get targetType => _targetType;
double get cropRotation => _cropRotation; // Getter pour le Plotting
double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius;
@@ -95,30 +79,19 @@ class AnalysisProvider extends ChangeNotifier {
int get totalScore => _scoreResult?.totalScore ?? 0;
int get shotCount => _shots.length;
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
// Distortion correction getters
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
DistortionParameters? get distortionParams => _distortionParams;
String? get correctedImagePath => _correctedImagePath;
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
/// Modifie et mémorise la rotation de l'image pour le Plotting
void setCropRotation(double rotation) {
_cropRotation = rotation;
notifyListeners();
}
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath
: _imagePath;
/// Analyze an image
///
/// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set.
/// Charge l'image et initialise les paramètres de cible par défaut.
/// Le placement des impacts et la calibration se font ensuite manuellement.
Future<void> analyzeImage(
String imagePath,
TargetType targetType, {
bool autoAnalyze = true,
Offset? manualCenter,
}) async {
_state = AnalysisState.loading;
_imagePath = imagePath;
@@ -135,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 = 0.5;
_targetCenterY = 0.5;
_targetCenterX = manualCenter?.dx ?? 0.5;
_targetCenterY = manualCenter?.dy ?? 0.5;
_targetRadius = 0.4;
_targetInnerRadius = 0.04;
// Initialize empty shots list
_shots = [];
_state = AnalysisState.success;
notifyListeners();
return;
}
// Detect target and impacts
final result = _detectionService.detectTarget(imagePath, targetType);
if (!result.success) {
_state = AnalysisState.error;
_errorMessage = result.errorMessage;
notifyListeners();
return;
}
_targetCenterX = result.centerX;
_targetCenterY = result.centerY;
_targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts
_shots = result.impacts.map((impact) {
return Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
sessionId: '', // Will be set when saving
);
}).toList();
// Calculate scores
_recalculateScores();
// Calculate grouping
_recalculateGrouping();
_state = AnalysisState.success;
notifyListeners();
} catch (e) {
@@ -194,7 +127,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a manual shot
void addShot(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
_shots.add(shot);
_recalculateScores();
@@ -202,6 +135,15 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Synthèse).
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
void clearShots() {
_shots.clear();
_recalculateScores();
_recalculateGrouping();
notifyListeners();
}
/// Remove a shot
void removeShot(String shotId) {
_shots.removeWhere((shot) => shot.id == shotId);
@@ -210,19 +152,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Move a shot to a new position
void moveShot(String shotId, double newX, double newY) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
final newScore = _calculateShotScore(newX, newY);
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
_recalculateScores();
_recalculateGrouping();
notifyListeners();
}
/// Update a shot's score manually
void updateShotScore(String shotId, int newScore) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
@@ -233,273 +162,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({
int darkThreshold = 80,
int minImpactSize = 20,
int maxImpactSize = 500,
double minCircularity = 0.6,
double minFillRatio = 0.5,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = ImpactDetectionSettings(
darkThreshold: darkThreshold,
minImpactSize: minImpactSize,
maxImpactSize: maxImpactSize,
minCircularity: minCircularity,
minFillRatio: minFillRatio,
);
final detectedImpacts = _detectionService.detectImpactsOnly(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
///
/// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes
/// de compilation. Cette méthode retourne 0 (aucun impact détecté).
/// Utiliser autoDetectImpacts() à la place.
///
/// Utilise les algorithmes OpenCV pour une détection plus robuste:
/// - Transformation de Hough pour détecter les cercles
/// - Analyse de contours avec filtrage par circularité
Future<int> autoDetectImpactsWithOpenCV({
double cannyThreshold1 = 50,
double cannyThreshold2 = 150,
double minDist = 20,
double param1 = 100,
double param2 = 30,
int minRadius = 5,
int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5,
bool useContourDetection = true,
double minCircularity = 0.6,
double minContourArea = 50,
double maxContourArea = 5000,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = OpenCVDetectionSettings(
cannyThreshold1: cannyThreshold1,
cannyThreshold2: cannyThreshold2,
minDist: minDist,
param1: param1,
param2: param2,
minRadius: minRadius,
maxRadius: maxRadius,
blurSize: blurSize,
useContourDetection: useContourDetection,
minCircularity: minCircularity,
minContourArea: minContourArea,
maxContourArea: maxContourArea,
);
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings: settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Detect impacts with OpenCV using reference points
Future<int> detectFromReferencesWithOpenCV({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_referenceImpacts.length < 2) {
return 0;
}
// Convertir les références
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
references,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
_referenceImpacts.add(shot);
notifyListeners();
}
/// Remove a reference impact
void removeReferenceImpact(String shotId) {
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
_learnedCharacteristics = null;
notifyListeners();
}
/// Clear all reference impacts
void clearReferenceImpacts() {
_referenceImpacts.clear();
_learnedCharacteristics = null;
notifyListeners();
}
/// Learn characteristics from reference impacts
bool learnFromReferences() {
if (_imagePath == null || _referenceImpacts.length < 2) return false;
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
_imagePath!,
references,
);
notifyListeners();
return _learnedCharacteristics != null;
}
/// Auto-detect impacts using learned reference characteristics
Future<int> detectFromReferences({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null ||
_targetType == null ||
_learnedCharacteristics == null) {
return 0;
}
final detectedImpacts = _detectionService.detectImpactsFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
_learnedCharacteristics!,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Adjust target position
void adjustTargetPosition(
double centerX,
@@ -508,17 +170,20 @@ class AnalysisProvider extends ChangeNotifier {
double radius, {
int? ringCount,
List<double>? ringRadii,
double zoomScale = 1.0,
Offset offset = Offset.zero,
}) {
_targetCenterX = centerX;
_targetCenterY = centerY;
_targetInnerRadius = innerRadius;
_targetRadius = radius;
_targetCenterX = (centerX - offset.dx) / zoomScale;
_targetCenterY = (centerY - offset.dy) / zoomScale;
_targetRadius = radius / zoomScale;
_targetInnerRadius = innerRadius / zoomScale;
if (ringCount != null) {
_ringCount = ringCount;
}
if (ringRadii != null) {
// CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement !
_ringRadii = ringRadii;
}
// Recalculate all shot scores based on new target position
_shots = _shots.map((shot) {
@@ -530,121 +195,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio =
1.0; // The corrected image is always square (side x side)
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
print('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
notifyListeners();
}
/// Applique la correction de distorsion à l'image
/// Crée une nouvelle image corrigée et la sauvegarde
Future<void> applyDistortionCorrection() async {
if (_imagePath == null || _distortionParams == null) return;
try {
_correctedImagePath = await _distortionService.applyCorrection(
_imagePath!,
_distortionParams!,
);
_distortionCorrectionEnabled = true;
notifyListeners();
} catch (e) {
_errorMessage = 'Erreur lors de la correction: $e';
notifyListeners();
}
}
/// Active ou désactive l'affichage de l'image corrigée
void setDistortionCorrectionEnabled(bool enabled) {
if (enabled && _correctedImagePath == null && _distortionParams != null) {
// Si on active mais pas encore d'image corrigée, la créer
applyDistortionCorrection();
} else {
_distortionCorrectionEnabled = enabled;
notifyListeners();
}
}
/* version deux a tester*/
/// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel)
_distortionParams = _distortionService.calculateDistortionFromCalibration(
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
imageAspectRatio: _imageAspectRatio,
);
// 2. Vérification si une correction est réellement nécessaire
if (_distortionParams != null && _distortionParams!.needsCorrection) {
// 3. Application immédiate de la transformation (méthode asynchrone)
await applyDistortionCorrection();
} else {
notifyListeners(); // On prévient quand même si pas de correction
}
}
Future<void> runFullDistortionWorkflow() async {
_state = AnalysisState.loading; // Affiche un spinner sur votre UI
notifyListeners();
try {
calculateDistortion(); // Calcule les paramètres
await applyDistortionCorrection(); // Génère le fichier corrigé
_distortionCorrectionEnabled = true; // Active l'affichage
_state = AnalysisState.success;
} catch (e) {
_errorMessage = "Erreur de rendu : $e";
_state = AnalysisState.error;
} finally {
notifyListeners();
}
}
/* fin section deux a tester*/
int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) {
return _scoreCalculatorService.calculateConcentricScore(
@@ -688,16 +238,69 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
}
/// Exporte l'image et le json vers le backend IA.
/// [sessionId], [distance] et [caliber] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
///
/// Le nom de l'arme n'est volontairement pas transmis : il est souvent
/// personnalisé par l'utilisateur et n'apporte rien au modèle de détection.
Future<AiExportResult> exportToAiBackend({
String? sessionId,
int? distance,
String? caliber,
}) async {
if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
notifyListeners();
return AiExportResult.error(
code: 'MISSING_DATA',
message: "Impossible d'exporter : image ou type de cible manquant.",
);
}
final service = AiExportService();
_state = AnalysisState.loading;
notifyListeners();
final result = await service.exportData(
imagePath: _imagePath!,
sessionId: sessionId ?? 'export',
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
shots: _shots,
distanceMeters: distance ?? 25,
caliber: caliber ?? 'unknown',
);
_state = AnalysisState.success;
if (!result.isSuccess) {
_errorMessage = result.message;
}
notifyListeners();
return result;
}
/// Save the session
Future<Session> saveSession({String? notes}) async {
Future<TargetAnalysis> saveSession({
String? notes,
String? sessionId,
String? weaponName,
String? weaponId,
int? distance,
DateTime? date,
}) async {
if (_imagePath == null || _targetType == null) {
throw Exception('Cannot save: missing image or target type');
}
final session = await _sessionRepository.createSession(
targetType: _targetType!,
final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
imagePath: _imagePath!,
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(),
sessionId: sessionId ?? 'standalone',
targetType: _targetType!,
shots: _shots,
totalScore: totalScore,
groupingDiameter: _groupingResult?.diameter,
groupingCenterX: _groupingResult?.centerX,
@@ -708,11 +311,41 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius,
);
// Update shots with session ID
_shots = session.shots;
notifyListeners();
if (sessionId != null && sessionId != 'standalone') {
final existingSession = await _sessionRepository.getSession(sessionId);
if (existingSession != null) {
await _sessionRepository.addAnalysisToSession(
sessionId,
targetAnalysis,
);
} else {
// CORRECTION : Utilise la date injectée plutôt que DateTime.now()
await _sessionRepository.createSession(
id: sessionId,
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
maxShotsPerTarget: shotCount,
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
date: date ?? DateTime.now(),
);
}
} else {
// CORRECTION : S'applique aussi au flux alternatif sans ID préalable
await _sessionRepository.createSession(
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
maxShotsPerTarget: shotCount,
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
date: date ?? DateTime.now(),
);
}
return session;
notifyListeners();
return targetAnalysis;
}
/// Reset the provider
@@ -721,6 +354,7 @@ class AnalysisProvider extends ChangeNotifier {
_errorMessage = null;
_imagePath = null;
_targetType = null;
_cropRotation = 0.0;
_targetCenterX = 0.5;
_targetCenterY = 0.5;
_targetRadius = 0.4;
@@ -731,11 +365,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 {
@@ -40,10 +39,19 @@ class TargetCalibration extends StatefulWidget {
});
@override
State<TargetCalibration> createState() => _TargetCalibrationState();
State<TargetCalibration> createState() => TargetCalibrationState();
}
class _TargetCalibrationState extends State<TargetCalibration> {
class TargetCalibrationState extends State<TargetCalibration> {
/// Bornes du rayon global (mesurées pour laisser de la liberté sans
/// débordement incontrôlé).
static const double minRadius = 0.3;
static const double maxRadius = 0.95;
/// Bornes de l'espacement : max bridé à 0.70 pour confiner le dernier cercle.
static const double minSpacing = 0.01;
static const double maxSpacing = 0.70;
late double _centerX;
late double _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();
}
void _initRingRadii() {
if (widget.initialRingRadii != null &&
widget.initialRingRadii!.length == _ringCount) {
/// Fige et propage la calibration courante vers le provider.
///
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
/// correspond exactement au dernier réglage validé, sans dépendre d'un
/// éventuel rebuild intermédiaire.
void commitCalibration() {
_notifyChange();
}
void _initRingRadii({bool forceRecalculate = false}) {
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
_ringRadii = List.from(_originalRingRadii!);
} else if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!);
} else {
// Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius;
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
});
}
}
@@ -85,12 +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,10 +162,23 @@ class _TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) {
final size = constraints.biggest;
// 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(
onPanStart: (details) => _onPanStart(details, size),
onPanUpdate: (details) => _onPanUpdate(details, size),
onPanEnd: (_) => _onPanEnd(),
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(
@@ -136,8 +190,8 @@ class _TargetCalibrationState extends State<TargetCalibration> {
ringRadii: _ringRadii,
targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius,
isDraggingInnerRadius: _isDraggingInnerRadius,
isDraggingRadius: false,
isDraggingInnerRadius: false,
),
),
);
@@ -145,83 +199,147 @@ class _TargetCalibrationState extends State<TargetCalibration> {
);
}
void _onPanStart(DragStartDetails details, Size size) {
final tapX = details.localPosition.dx / size.width;
final tapY = details.localPosition.dy / size.height;
// ---------------------------------------------------------------------------
// API publique pilotée par l'écran hôte (panneau de réglages hors de l'image)
// ---------------------------------------------------------------------------
// Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
/// Espacement courant (rayon du premier anneau, en fraction du rayon global).
double get spacingRatio => _currentEspacementRatio;
// Check if tapping on outer radius handle
final minDim = math.min(size.width, size.height);
final outerRadius = _radius;
final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY;
final distToOuterHandle = _distance(
tapX,
tapY,
radiusHandleX.clamp(0.0, 1.0),
radiusHandleY.clamp(0.0, 1.0),
);
/// Mode d'espacement manuel actif ou non.
bool get isSpacingModeEnabled => _showEspacement;
// Check if tapping on inner radius handle (top edge of innermost circle)
final actualInnerRadius = _innerRadius;
final innerHandleX = _centerX;
final innerHandleY = _centerY - actualInnerRadius * minDim / size.height;
final distToInnerHandle = _distance(
tapX,
tapY,
innerHandleX.clamp(0.0, 1.0),
innerHandleY.clamp(0.0, 1.0),
);
// Increase touch target size slightly for handles
if (distToCenter < 0.05) {
/// Applique une nouvelle taille globale (rayon normalisé).
void setRadius(double value) {
setState(() {
_isDraggingCenter = true;
_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);
});
} else if (distToOuterHandle < 0.05) {
setState(() {
_isDraggingRadius = true;
});
} else if (distToInnerHandle < 0.05) {
setState(() {
_isDraggingInnerRadius = true;
});
} else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center
setState(() {
_isDraggingCenter = true;
});
}
_notifyChange();
}
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);
/// 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(() {
if (_isDraggingCenter) {
// Move center
_centerX = _centerX + deltaX;
_centerY = _centerY + deltaY;
} else if (_isDraggingRadius) {
// Adjust outer radius
final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0);
_initRingRadii(); // Recalculate linear separation
} else if (_isDraggingInnerRadius) {
// Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY)
final newInnerRadius = _innerRadius - deltaY * (size.height / minDim);
_innerRadius = newInnerRadius.clamp(
0.01,
math.max(0.01, _radius - 0.01),
);
_initRingRadii(); // Recalculate linear separation
_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),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 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)),
const SizedBox(width: 40),
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
],
),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
],
),
const SizedBox(width: 12),
// 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)),
margin: const EdgeInsets.all(2),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 28),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
);
}
void _notifyChange() {
widget.onCalibrationChanged(
_centerX,
_centerY,
@@ -232,7 +350,31 @@ class _TargetCalibrationState extends State<TargetCalibration> {
);
}
void _onPanEnd() {
void _moveCenterByPixels(double dx, double dy, Size size) {
setState(() {
_centerX = _centerX + (dx / size.width);
_centerY = _centerY + (dy / size.height);
});
_notifyChange();
}
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
setState(() {
if (details.pointerCount == 2) {
_radius = (_baseRadiusBeforeScale * details.scale).clamp(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 _onScaleEnd() {
setState(() {
_isDraggingCenter = false;
_isDraggingRadius = false;
@@ -277,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);
@@ -285,43 +426,17 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
}
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) {
final crosshairLinePaint = Paint()
..color = AppTheme.successColor.withValues(alpha: 0.5)
..strokeWidth = 1;
canvas.drawLine(
Offset(0, centerPx.dy),
Offset(size.width, centerPx.dy),
crosshairLinePaint,
);
canvas.drawLine(
Offset(centerPx.dx, 0),
Offset(centerPx.dx, size.height),
crosshairLinePaint,
);
final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
}
// Draw center handle
_drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw inner radius handle
_drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx);
// Draw instructions
_drawInstructions(canvas, size);
}
void _drawConcentricZones(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) {
final ratio = i / ringCount;
@@ -342,13 +457,10 @@ class _CalibrationPainter extends CustomPainter {
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.white.withValues(alpha: 0.6);
..color = Colors.red.withValues(alpha: 0.8);
// Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) {
final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i];
@@ -356,22 +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;
@@ -386,251 +490,49 @@ class _CalibrationPainter extends CustomPainter {
);
textPainter.layout();
// Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(
canvas,
Offset(labelX - textPainter.width / 2, labelY),
);
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
}
}
}
void _drawSilhouetteZones(
Canvas canvas,
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now)
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
final silhouetteWidth = radius * 0.8;
final silhouetteHeight = radius * 2;
paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect(
Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint,
);
canvas.drawRect(Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), paint);
}
void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle
final outerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 3;
final outerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.stroke..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint);
// Inner dot
final innerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.fill;
final innerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint);
// Crosshair
final crossPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..strokeWidth = 2;
canvas.drawLine(
Offset(center.dx - 20, center.dy),
Offset(center.dx - 8, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx + 8, center.dy),
Offset(center.dx + 20, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy - 20),
Offset(center.dx, center.dy - 8),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy + 8),
Offset(center.dx, center.dy + 20),
crossPaint,
);
}
void _drawRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Radius handle on the right edge of the outermost ring
final actualHandleX = center.dx + baseRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
// Check if handle is clamped (radius extends beyond visible area)
final isClamped = actualHandleX > size.width - 20;
final paint = Paint()
..color = isDraggingRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : AppTheme.warningColor)
..style = PaintingStyle.fill;
// Draw handle as a small circle with arrows
canvas.drawCircle(handlePos, 14, paint);
// Draw arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Left arrow
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy + 4),
arrowPaint,
);
// Right arrow
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy + 4),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'EXT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16),
);
}
void _drawInnerRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double innerRadiusPx,
) {
// Inner radius handle on the top edge of the innermost ring
final actualHandleY = center.dy - innerRadiusPx;
final clampedHandleX = center.dx.clamp(20.0, size.width - 20);
final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
final isClamped = actualHandleY < 20.0;
final paint = Paint()
..color = isDraggingInnerRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : Colors.purpleAccent)
..style = PaintingStyle.fill;
// Draw handle
canvas.drawCircle(handlePos, 14, paint);
// Up/Down arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Up arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx - 4, handlePos.dy - 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx + 4, handlePos.dy - 8),
arrowPaint,
);
// Down arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx - 4, handlePos.dy + 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx + 4, handlePos.dy + 8),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'INT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy - 24),
);
final crossPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy + 4), Offset(center.dx, center.dy + 20), crossPaint);
}
void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon';
final textPainter = TextPainter(
text: TextSpan(
text: instruction,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
backgroundColor: Colors.black54,
),
),
text: TextSpan(text: instruction, style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 12, backgroundColor: Colors.black54)),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset((size.width - textPainter.width) / 2, size.height - 30),
);
textPainter.paint(canvas, Offset((size.width - textPainter.width) / 2, size.height - 30));
}
@override
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) {
bool shouldRepaint(CustomPainter oldDelegate) {
if (oldDelegate is! _CalibrationPainter) return true;
return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY ||
radius != oldDelegate.radius ||
@@ -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
+463 -35
View File
@@ -1,18 +1,25 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
// Importations ajustées selon ton arborescence à gauche
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../../services/image_crop_service.dart';
import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget {
final String imagePath;
final TargetType targetType;
final double? initialScale;
final Offset? initialOffset;
const CropScreen({
super.key,
required this.imagePath,
required this.targetType,
this.initialScale,
this.initialOffset,
});
@override
@@ -20,6 +27,49 @@ 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
double _scale = 1.0;
double _baseScale = 1.0;
Offset _offset = Offset.zero;
Offset _startOffset = Offset.zero;
Offset _startFocalPoint = Offset.zero;
// PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés
double _rotation = 0.0;
bool _isLoading = false;
bool _imageLoaded = false;
Size? _imageSize;
late Size _viewportSize;
late double _cropSize;
@override
void initState() {
super.initState();
_loadImageInfo();
}
Future<void> _loadImageInfo() async {
final file = File(widget.imagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) {
setState(() {
_imageSize = Size(
decodedImage.width.toDouble(),
decodedImage.height.toDouble(),
);
_imageLoaded = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -29,55 +79,149 @@ class _CropScreenState extends State<CropScreen> {
elevation: 0,
centerTitle: true,
title: const Text(
'Validation image',
'Centrage de la cible',
style: TextStyle(color: Colors.white, fontSize: 18),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
actions: [
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Réinitialiser l\'orientation',
onPressed: () {
setState(() {
_rotation = 0.0;
_scale = 1.0;
_offset = Offset.zero;
_initializeImagePosition();
});
},
),
body: Column(
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
children: [
// L'image capturée
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(
'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),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(widget.imagePath),
fit: BoxFit.contain,
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
),
),
),
),
),
),
// Bouton 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),
child: ElevatedButton.icon(
onPressed: () {
// Logique de modification à venir
},
icon: const Icon(Icons.crop_rotate),
label: const Text('Modifier'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Rotation de l\'image',
style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600),
),
Text(
'${_rotation.toStringAsFixed(1)}°',
style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold),
),
],
),
Row(
children: [
// 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;
});
},
),
],
),
],
),
),
// Boutons du bas
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
child: Row(
children: [
Expanded(
@@ -88,30 +232,20 @@ class _CropScreenState extends State<CropScreen> {
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Recommencer', style: TextStyle(color: Colors.white)),
child: const Text('Retour', style: TextStyle(color: Colors.white)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: widget.imagePath,
targetType: widget.targetType,
),
),
);
},
onPressed: _onCropConfirm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Valider', style: TextStyle(fontWeight: FontWeight.bold)),
child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
@@ -121,4 +255,298 @@ class _CropScreenState extends State<CropScreen> {
),
);
}
Widget _buildInteractiveCrop() {
return LayoutBuilder(
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
// visée = toute la zone visible → aucun bord noir autour du cadre.
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
_initializeImagePosition();
}
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: Stack(
children: [
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
Positioned.fill(
child: Center(
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scaleByDouble(_scale, _scale, _scale, 1.0)
..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
fit: BoxFit.cover,
width: _viewportSize.width,
height: _viewportSize.height,
),
),
),
),
// 2. Les lignes vertes de visée prolongées
Positioned.fill(
child: IgnorePointer(
child: Stack(
children: [
Center(
child: Container(
width: double.infinity,
height: 1.5,
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
),
),
],
),
),
),
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
Positioned.fill(
child: IgnorePointer(
child: ColorFiltered(
colorFilter: ColorFilter.mode(
const Color(0xFF101214),
BlendMode.srcOut,
),
child: Stack(
children: [
Center(
child: Container(
width: _cropSize,
height: _cropSize,
color: Colors.black,
),
),
],
),
),
),
),
// 4. Ton overlay avec les coins blancs
Positioned.fill(
child: IgnorePointer(
child: CropOverlay(cropSize: _cropSize, showGrid: false),
),
),
],
),
);
},
);
}
void _initializeImagePosition() {
if (_imageSize == null) return;
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
_scale = widget.initialScale ?? 1.0;
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
if (widget.initialOffset != null) {
_offset = widget.initialOffset!;
} else {
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
}
}
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
// Les 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;
_startOffset = _offset;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
final delta = details.focalPoint - _startFocalPoint;
_offset = _startOffset + delta;
});
}
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
final double displayPerSourcePx = math.max(
_viewportSize.width / _imageSize!.width,
_viewportSize.height / _imageSize!.height,
);
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
// sont remplis en noir → la position choisie est respectée à l'identique.
final croppedImagePath = await _cropService.cropViewport(
sourcePath: widget.imagePath,
offsetDx: _offset.dx,
offsetDy: _offset.dy,
displayPerSourcePx: displayPerSourcePx,
cropSizeDisplay: _cropSize,
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
// il n'agrandit pas le rendu de sortie.
zoomScale: _scale,
rotationDegrees: _rotation,
);
const targetCenterX = 0.5;
const targetCenterY = 0.5;
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: croppedImagePath,
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
originalImagePath: widget.imagePath,
targetType: widget.targetType,
initialCenterX: targetCenterX,
initialCenterY: targetCenterY,
cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
),
),
);
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
);
}
}
}
}
+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
@@ -0,0 +1,628 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart';
import '../../data/models/maintenance.dart';
import '../../data/repositories/session_repository.dart';
class WeaponDetailScreen extends StatefulWidget {
final Weapon weapon;
const WeaponDetailScreen({super.key, required this.weapon});
@override
State<WeaponDetailScreen> createState() => _WeaponDetailScreenState();
}
class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon;
List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0;
int _sessionCount = 0;
bool _isLoading = true;
@override
void initState() {
super.initState();
_weapon = widget.weapon;
_loadData();
}
Future<void> _loadData() async {
final repository = context.read<SessionRepository>();
final history = await repository.getMaintenanceHistory(_weapon.id);
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
if (mounted) {
setState(() {
_maintenance = history;
_totalRounds = rounds;
_sessionCount = sessions;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_weapon.customName ?? _weapon.name),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: _showEditWeaponDialog,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatsSection(),
const SizedBox(height: 24),
_buildInfoCard(),
const SizedBox(height: 24),
_buildOptionsCard(),
const SizedBox(height: 24),
_buildHistorySection(),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _showAddMaintenanceDialog,
label: const Text('Entretien'),
icon: const Icon(Icons.build),
),
);
}
Widget _buildStatsSection() {
return Row(
children: [
Expanded(
child: _buildStatCard(
'Total Tirs',
_totalRounds.toString(),
Icons.ads_click,
AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
'Sessions',
_sessionCount.toString(),
Icons.history,
Colors.orange,
),
),
],
);
}
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 8),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildInfoRow('Modèle', _weapon.name),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Type', style: TextStyle(color: Colors.grey)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
WeaponTypeIcon(type: _weapon.type, size: 16),
const SizedBox(width: 6),
Text(
_weapon.type.displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
],
),
),
_buildInfoRow('Calibre', _weapon.caliber),
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
const Text('Notes:', style: TextStyle(fontWeight: FontWeight.w500)),
Text(_weapon.notes!, style: const TextStyle(color: Colors.grey)),
],
],
),
),
);
}
Widget _buildOptionsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
IconButton(
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
style: IconButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
),
onPressed: _showEditAccessoriesDialog,
),
],
),
const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
_buildInfoRow('Détente', _weapon.trigger ?? 'Standard'),
if (_weapon.customName != null)
_buildInfoRow('Surnom', _weapon.customName!),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(color: Colors.grey)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
}
Widget _buildHistorySection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Historique d\'entretien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
const SizedBox(height: 8),
if (_maintenance.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Center(child: Text('Aucun entretien enregistré', style: TextStyle(color: Colors.grey))),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _maintenance.length,
itemBuilder: (context, index) {
final entry = _maintenance[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getMaintenanceColor(entry.type).withValues(alpha: 0.1),
child: Icon(_getMaintenanceIcon(entry.type), color: _getMaintenanceColor(entry.type), size: 20),
),
title: Text(entry.type.displayName),
subtitle: Text(entry.description),
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
onLongPress: () => _confirmDeleteMaintenance(entry),
),
);
},
),
],
);
}
Color _getMaintenanceColor(MaintenanceType type) {
switch (type) {
case MaintenanceType.cleaning: return Colors.blue;
case MaintenanceType.repair: return Colors.red;
case MaintenanceType.inspection: return Colors.green;
case MaintenanceType.upgrade: return Colors.purple;
case MaintenanceType.other: return Colors.grey;
}
}
IconData _getMaintenanceIcon(MaintenanceType type) {
switch (type) {
case MaintenanceType.cleaning: return Icons.opacity;
case MaintenanceType.repair: return Icons.build;
case MaintenanceType.inspection: return Icons.visibility;
case MaintenanceType.upgrade: return Icons.trending_up;
case MaintenanceType.other: return Icons.more_horiz;
}
}
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
// (ex: Modérateur) se retrouve caché sous le clavier.
Widget _autoScrollOnFocus(Widget child) {
return Builder(
builder: (context) => Focus(
canRequestFocus: false,
skipTraversal: true,
onFocusChange: (hasFocus) {
if (!hasFocus) return;
// On attend que le clavier ait fini de redimensionner la vue.
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
});
},
child: child,
),
);
}
void _showEditWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber);
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
final magCapController = TextEditingController(text: _weapon.magazineCapacity.toString());
final notesController = TextEditingController(text: _weapon.notes);
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
WeaponType selectedType = _weapon.type;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Modifier l\'arme'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_autoScrollOnFocus(TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
)),
DropdownButtonFormField<WeaponType>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
WeaponTypeIcon(type: t, size: 20),
const SizedBox(width: 10),
Text(t.displayName),
],
),
),
)
.toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
_autoScrollOnFocus(TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'),
)),
const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
_autoScrollOnFocus(Row(
children: [
Expanded(
child: TextField(
controller: magCountController,
decoration: const InputDecoration(labelText: 'Chargeurs'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: magCapController,
decoration: const InputDecoration(labelText: 'Capacité'),
keyboardType: TextInputType.number,
),
),
],
)),
_autoScrollOnFocus(TextField(
controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2,
)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Enregistrer')),
],
),
),
);
if (result == true) {
final updatedWeapon = _weapon.copyWith(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
notes: notesController.text,
optic: opticController.text.isEmpty ? null : opticController.text,
silencer: silencerController.text.isEmpty ? null : silencerController.text,
trigger: triggerController.text.isEmpty ? null : triggerController.text,
customName: customNameController.text.isEmpty ? null : customNameController.text,
);
await repository.updateWeapon(updatedWeapon);
if (!mounted) return;
setState(() {
_weapon = updatedWeapon;
});
}
}
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
// pouvoir comparer les scores selon la configuration utilisée.
void _showEditAccessoriesDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier les accessoires'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
'pour pouvoir comparer les scores selon la configuration.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
_autoScrollOnFocus(TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
)),
_autoScrollOnFocus(TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
)),
_autoScrollOnFocus(TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
)),
_autoScrollOnFocus(TextField(
controller: customNameController,
decoration: const InputDecoration(
labelText: 'Surnom de la configuration',
hintText: 'ex: Echelon + Holosun',
),
)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
],
),
);
if (result != true || !mounted) return;
final newOptic = opticController.text.isEmpty ? null : opticController.text;
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
// Rien à faire si aucun accessoire n'a changé.
final unchanged = newOptic == _weapon.optic &&
newSilencer == _weapon.silencer &&
newTrigger == _weapon.trigger;
if (unchanged) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
);
return;
}
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
if (newCustomName == null) {
final accessories = [newOptic, newSilencer, newTrigger]
.where((a) => a != null && a.isNotEmpty)
.join(' / ');
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
}
final newWeapon = await repository.addWeapon(
name: _weapon.name,
type: _weapon.type,
caliber: _weapon.caliber,
magazineCount: _weapon.magazineCount,
magazineCapacity: _weapon.magazineCapacity,
notes: _weapon.notes,
optic: newOptic,
silencer: newSilencer,
trigger: newTrigger,
customName: newCustomName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
);
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
);
}
void _showAddMaintenanceDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning;
DateTime selectedDate = DateTime.now();
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter un entretien'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<MaintenanceType>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: descController,
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
maxLines: 2,
),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() => selectedDate = picked);
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date',
suffixIcon: Icon(Icons.calendar_today, size: 18),
),
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
],
),
),
);
if (result == true && descController.text.isNotEmpty) {
await repository.addMaintenanceEntry(
weaponId: _weapon.id,
type: selectedType,
description: descController.text,
roundsSinceLast: _totalRounds,
date: selectedDate,
);
if (!mounted) return;
_loadData();
}
}
void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Supprimer cette entrée de l\'historique ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true) {
await repository.deleteMaintenanceEntry(entry.id);
if (!mounted) return;
_loadData();
}
}
}
+463
View File
@@ -0,0 +1,463 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/glass_container.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget {
final int refreshTick;
const WeaponListScreen({super.key, this.refreshTick = 0});
@override
State<WeaponListScreen> createState() => _WeaponListScreenState();
}
class _WeaponListScreenState extends State<WeaponListScreen> {
List<Weapon> _weapons = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_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();
if (mounted) {
setState(() {
_weapons = weapons;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
extendBody: true,
appBar: AppBar(
title: const Text('Mon Armurerie'),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Ajouter une arme',
onPressed: _showAddWeaponDialog,
),
const SizedBox(width: 8),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _weapons.isEmpty
? _buildEmptyState(isDark)
: _buildWeaponList(isDark),
);
}
Widget _buildEmptyState(bool isDark) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GlassContainer(
borderRadius: 30,
padding: const EdgeInsets.all(28),
child: Icon(
Icons.shield_outlined,
size: 64,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 24),
Text(
'Aucune arme enregistrée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 8),
Text(
'Ajoutez vos armes pour suivre vos tirs, chargeurs et entretiens.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _showAddWeaponDialog,
icon: const Icon(Icons.add),
label: const Text('Ajouter ma première arme'),
),
],
),
),
);
}
Widget _buildWeaponList(bool isDark) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(
AppConstants.defaultPadding,
12,
AppConstants.defaultPadding,
100, // Espace pour le floating dock
),
itemCount: _weapons.length,
itemBuilder: (context, index) {
final weapon = _weapons[index];
final accessories = _accessoryChips(weapon, isDark);
final primaryColor = Theme.of(context).colorScheme.primary;
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: primaryColor,
borderColor: isDark
? primaryColor.withValues(alpha: 0.18)
: primaryColor.withValues(alpha: 0.12),
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(16),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => WeaponDetailScreen(weapon: weapon),
),
);
_loadWeapons();
},
onLongPress: () => _confirmDelete(weapon),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
primaryColor.withValues(
alpha: isDark ? 0.25 : 0.15,
),
primaryColor.withValues(
alpha: 0.05,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: primaryColor.withValues(
alpha: isDark ? 0.35 : 0.2,
),
),
),
child: WeaponTypeIcon(
type: weapon.type,
color: primaryColor,
size: 28,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
weapon.displayName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: isDark
? AppTheme.darkTextPrimary
: AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? AppTheme.darkBorder
: AppTheme.lightBorder,
),
),
child: Text(
weapon.caliber,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
const SizedBox(width: 8),
Text(
weapon.type.displayName,
style: TextStyle(
fontSize: 12,
color: isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary,
),
),
],
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${weapon.magazineCount} chargeur${weapon.magazineCount > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: isDark
? AppTheme.darkTextPrimary
: AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 2),
Text(
'${weapon.magazineCapacity} coups/ch.',
style: TextStyle(
fontSize: 11,
color: isDark
? AppTheme.darkTextMuted
: AppTheme.lightTextMuted,
),
),
],
),
],
),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
],
],
),
);
},
);
}
List<Widget> _accessoryChips(Weapon weapon, bool isDark) {
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: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.$1,
size: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
const SizedBox(width: 5),
Text(
item.$2,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
);
}).toList();
}
void _showAddWeaponDialog() async {
final repository = context.read<SessionRepository>();
final nameController = TextEditingController();
final caliberController = TextEditingController();
final magCountController = TextEditingController(text: '2');
final magCapController = TextEditingController(text: '15');
WeaponType selectedType = WeaponType.handgun;
final result = await showDialog<bool>(
context: context,
builder: (dialogCtx) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter une arme'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Nom de l\'arme',
hintText: 'ex: Glock 17 Gen 5',
),
),
const SizedBox(height: 12),
DropdownButtonFormField<WeaponType>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
WeaponTypeIcon(type: t, size: 20),
const SizedBox(width: 10),
Text(t.displayName),
],
),
),
)
.toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
const SizedBox(height: 12),
TextField(
controller: caliberController,
decoration: const InputDecoration(
labelText: 'Calibre',
hintText: 'ex: 9x19mm, .22 LR, .223 Rem',
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: magCountController,
decoration: const InputDecoration(labelText: 'Chargeurs'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: magCapController,
decoration: const InputDecoration(labelText: 'Capacité'),
keyboardType: TextInputType.number,
),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Ajouter'),
),
],
),
),
);
if (result == true && nameController.text.isNotEmpty) {
await repository.addWeapon(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
);
if (!mounted) return;
_loadWeapons();
}
}
void _confirmDelete(Weapon weapon) async {
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer l\'arme'),
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed == true) {
await repository.deleteWeapon(weapon.id);
if (!mounted) return;
_loadWeapons();
}
}
}
+175 -78
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart';
import '../../data/models/target_type.dart';
@@ -11,7 +10,9 @@ import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
final int refreshTick;
const HistoryScreen({super.key, this.refreshTick = 0});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
@@ -21,8 +22,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<Session> _sessions = [];
bool _isLoading = true;
TargetType? _filterType;
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
DateTimeRange? _selectedDateRange;
@override
@@ -31,22 +30,34 @@ class _HistoryScreenState extends State<HistoryScreen> {
_loadSessions();
}
@override
void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshTick != widget.refreshTick) {
_loadSessions();
}
}
Future<void> _loadSessions() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final repository = context.read<SessionRepository>();
final sessions = await repository.getAllSessions(targetType: _filterType);
final sessions = await repository.getAllSessions();
if (mounted) {
setState(() {
_sessions = sessions;
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_filterType != null) {
_sessions = _sessions
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
.toList();
}
if (_selectedDateRange != null) {
_sessions = _sessions.where((s) {
// On compare uniquement les dates (sans les heures) pour éviter les bugs
final sessionDate = DateTime(
s.createdAt.year,
s.createdAt.month,
@@ -85,27 +96,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
}
}
// --- MODIFICATION : Fonction DateRangePicker ---
Future<void> _pickDateRange() async {
final DateTimeRange? picked = await showDateRangePicker(
final picked = await showDateRangePicker(
context: context,
initialDateRange: _selectedDateRange,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.dark(
primary: AppTheme.primaryColor,
onPrimary: Colors.white,
surface: Theme.of(context).cardColor,
onSurface: Colors.white,
),
),
child: child!,
);
},
);
if (picked != null) {
@@ -116,76 +113,139 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
extendBody: true,
appBar: AppBar(
title: const Text('Historique'),
title: const Text('Carnet de Tir'),
actions: [
PopupMenuButton<TargetType?>(
icon: const Icon(Icons.filter_list),
onSelected: (type) {
setState(() => _filterType = type);
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(value: null, child: Text('Tous')),
...TargetType.values.map(
(type) =>
PopupMenuItem(value: type, child: Text(type.displayName)),
IconButton(
icon: Icon(
Icons.date_range_outlined,
color: _selectedDateRange != null ? AppTheme.primaryColor : null,
),
],
tooltip: 'Filtrer par date',
onPressed: _pickDateRange,
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
_buildFilterChips(isDark),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty
? _buildEmptyState()
: _buildContent(),
? _buildEmptyState(isDark)
: _buildContent(isDark),
),
_buildBottomFilterBar(),
if (_selectedDateRange != null) _buildActivePeriodBanner(isDark),
],
),
);
}
Widget _buildBottomFilterBar() {
Widget _buildFilterChips(bool isDark) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, -2),
color: isDark ? AppTheme.darkBackground : AppTheme.lightBackground,
border: Border(
bottom: BorderSide(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
width: 1,
),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildTypeFilterChip('Toutes les cibles', null),
const SizedBox(width: 8),
_buildTypeFilterChip(
'Cibles 1-10',
TargetType.concentric,
),
const SizedBox(width: 8),
_buildTypeFilterChip(
'Silhouettes',
TargetType.silhouette,
),
],
),
),
);
}
Widget _buildTypeFilterChip(String label, TargetType? type) {
final isSelected = _filterType == type;
return ChoiceChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _filterType = type);
_loadSessions();
}
},
);
}
Widget _buildActivePeriodBanner(bool isDark) {
final start = DateFormat('dd/MM/yy').format(_selectedDateRange!.start);
final end = DateFormat('dd/MM/yy').format(_selectedDateRange!.end);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
border: Border(
top: BorderSide(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickDateRange,
icon: const Icon(Icons.date_range, size: 18),
label: Text(
_selectedDateRange == null
? 'Choisir une période'
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
const Icon(Icons.event, size: 18, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Période : $start - $end',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
),
if (_selectedDateRange != null)
IconButton(
icon: const Icon(Icons.close, color: AppTheme.errorColor),
onPressed: () {
const Spacer(),
InkWell(
onTap: () {
setState(() => _selectedDateRange = null);
_loadSessions();
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Text(
'Effacer',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.errorColor,
),
),
const SizedBox(width: 4),
const Icon(Icons.close, size: 14, color: AppTheme.errorColor),
],
),
),
),
],
),
@@ -193,45 +253,81 @@ class _HistoryScreenState extends State<HistoryScreen> {
);
}
Widget _buildEmptyState() {
Widget _buildEmptyState(bool isDark) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('Aucune session sur cette période'),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurfaceVariant,
shape: BoxShape.circle,
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Icon(
Icons.history_toggle_off,
size: 56,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 18),
Text(
'Aucune session trouvée',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 6),
Text(
_selectedDateRange != null || _filterType != null
? 'Essayez de réinitialiser vos filtres de recherche.'
: 'Vos sessions enregistrées apparaîtront ici.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
);
}
Widget _buildContent() {
Widget _buildContent(bool isDark) {
return RefreshIndicator(
onRefresh: _loadSessions,
color: AppTheme.primaryColor,
child: CustomScrollView(
slivers: [
if (_sessions.length >= 2 && _selectedDateRange == null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: HistoryChart(sessions: _sessions),
),
),
SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
delegate: SliverChildBuilderDelegate(
(context, index) {
final session = _sessions[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SessionListItem(
return SessionListItem(
session: session,
onTap: () => _openSessionDetail(session),
onDelete: () => _deleteSession(session),
),
);
}, childCount: _sessions.length),
},
childCount: _sessions.length,
),
),
),
],
@@ -251,21 +347,22 @@ class _HistoryScreenState extends State<HistoryScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
title: const Text('Supprimer la session'),
content: Text(
'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?',
'Voulez-vous supprimer définitivement la session du ${DateFormat('dd/MM/yyyy à HH:mm').format(session.createdAt)} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Supprimer',
style: TextStyle(color: AppTheme.errorColor),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
+140 -66
View File
@@ -1,10 +1,3 @@
/// Écran de détail d'une session.
///
/// Affiche la visualisation complète d'une session sauvegardée :
/// image de la cible avec overlay des impacts, scores recalculés,
/// statistiques de groupement et lien vers les statistiques détaillées.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -12,6 +5,8 @@ import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
@@ -20,7 +15,7 @@ import '../analysis/widgets/score_card.dart';
import '../analysis/widgets/grouping_stats.dart';
import '../statistics/statistics_screen.dart';
class SessionDetailScreen extends StatelessWidget {
class SessionDetailScreen extends StatefulWidget {
final Session session;
const SessionDetailScreen({
@@ -28,33 +23,40 @@ class SessionDetailScreen extends StatelessWidget {
required this.session,
});
@override
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
}
class _SessionDetailScreenState extends State<SessionDetailScreen> {
int _currentTargetIndex = 0;
@override
Widget build(BuildContext context) {
final scoreCalculator = context.read<ScoreCalculatorService>();
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
final currentAnalysis = widget.session.analyses[_currentTargetIndex];
final scoreResult = scoreCalculator.calculateScores(
shots: session.shots,
targetType: session.targetType,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
shots: currentAnalysis.shots,
targetType: currentAnalysis.targetType,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
targetRadius: currentAnalysis.targetRadius ?? 0.4,
);
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots);
final groupingResult = groupingAnalyzer.analyzeGrouping(currentAnalysis.shots);
return Scaffold(
appBar: AppBar(
title: Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
),
title: Text(widget.session.weapon),
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StatisticsScreen(singleSession: session),
builder: (_) => StatisticsScreen(singleSession: widget.session),
),
),
tooltip: 'Statistiques',
@@ -70,15 +72,22 @@ class SessionDetailScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Weapon and session overview
_buildSessionHeader(context),
// Target selector if multiple targets
if (widget.session.targetCount > 1)
_buildTargetSelector(),
// Target image with overlay
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
if (File(session.imagePath).existsSync())
if (File(currentAnalysis.imagePath).existsSync())
Image.file(
File(session.imagePath),
File(currentAnalysis.imagePath),
fit: BoxFit.contain,
)
else
@@ -89,14 +98,14 @@ class SessionDetailScreen extends StatelessWidget {
),
),
TargetOverlay(
shots: session.shots,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
targetType: session.targetType,
groupingCenterX: session.groupingCenterX,
groupingCenterY: session.groupingCenterY,
groupingDiameter: session.groupingDiameter,
shots: currentAnalysis.shots,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
targetRadius: currentAnalysis.targetRadius ?? 0.4,
targetType: currentAnalysis.targetType,
groupingCenterX: currentAnalysis.groupingCenterX,
groupingCenterY: currentAnalysis.groupingCenterY,
groupingDiameter: currentAnalysis.groupingDiameter,
),
],
),
@@ -107,31 +116,37 @@ class SessionDetailScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Session info
_buildSessionInfo(context),
// Analysis Info
_buildAnalysisInfo(context, currentAnalysis),
const SizedBox(height: 12),
// Score card
// Score card for current target
ScoreCard(
totalScore: session.totalScore,
shotCount: session.shotCount,
totalScore: currentAnalysis.totalScore,
shotCount: currentAnalysis.shotCount,
scoreResult: scoreResult,
targetType: session.targetType,
targetType: currentAnalysis.targetType,
),
const SizedBox(height: 12),
// Grouping stats
if (session.shotCount > 1)
// Grouping stats for current target
if (currentAnalysis.shotCount > 1)
GroupingStats(
groupingResult: groupingResult,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
),
// Notes
if (session.notes != null && session.notes!.isNotEmpty) ...[
// Notes for current target
if (currentAnalysis.notes != null && currentAnalysis.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context),
_buildNotesCard(context, currentAnalysis.notes!),
],
// Session notes
if (widget.session.notes != null && widget.session.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context, widget.session.notes!, title: 'Notes de session'),
],
],
),
@@ -142,16 +157,83 @@ class SessionDetailScreen extends StatelessWidget {
);
}
Widget _buildSessionInfo(BuildContext context) {
Widget _buildSessionHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat('EEEE dd MMMM yyyy', 'fr_FR').format(widget.session.createdAt),
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
DateFormat('HH:mm').format(widget.session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Score Total: ${widget.session.totalScore}',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryColor),
),
Text(
'${widget.session.totalShots} tirs au total',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
],
),
);
}
Widget _buildTargetSelector() {
return Container(
height: 50,
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: widget.session.targetCount,
itemBuilder: (context, index) {
final isSelected = _currentTargetIndex == index;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text('Cible ${index + 1}'),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _currentTargetIndex = index);
}
},
),
);
},
),
);
}
Widget _buildAnalysisInfo(BuildContext context, TargetAnalysis analysis) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
session.targetType == session.targetType
? Icons.track_changes
: Icons.person,
analysis.targetType == TargetType.concentric ? Icons.track_changes : Icons.person,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
@@ -160,14 +242,11 @@ class SessionDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.targetType.displayName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
analysis.targetType.displayName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR')
.format(session.createdAt),
'Cible ${_currentTargetIndex + 1} sur ${widget.session.targetCount}',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -179,7 +258,7 @@ class SessionDetailScreen extends StatelessWidget {
);
}
Widget _buildNotesCard(BuildContext context) {
Widget _buildNotesCard(BuildContext context, String notes, {String title = 'Notes'}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
@@ -188,18 +267,16 @@ class SessionDetailScreen extends StatelessWidget {
children: [
Row(
children: [
const Icon(Icons.notes, color: AppTheme.primaryColor),
const Icon(Icons.notes, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const Divider(),
Text(session.notes!),
Text(notes),
],
),
),
@@ -211,7 +288,7 @@ class SessionDetailScreen extends StatelessWidget {
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Voulez-vous vraiment supprimer cette session?'),
content: const Text('Voulez-vous vraiment supprimer cette session entière ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -231,20 +308,17 @@ class SessionDetailScreen extends StatelessWidget {
if (confirmed == true && context.mounted) {
try {
final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id);
await repository.deleteSession(widget.session.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
const SnackBar(content: Text('Session supprimée')),
);
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
);
}
}
@@ -155,7 +155,7 @@ class HistoryChart extends StatelessWidget {
return touchedSpots.map((spot) {
final session = displaySessions[spot.x.toInt()];
return LineTooltipItem(
'Score: ${session.totalScore}\n${session.shotCount} tirs',
'Score: ${session.totalScore}\n${session.totalShots} tirs',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
@@ -1,15 +1,9 @@
/// Item de liste représentant une session.
///
/// Affiche les informations clés d'une session (date, type de cible,
/// score, nombre de tirs) avec miniature de l'image et actions de suppression.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../data/models/session.dart';
import '../../../data/models/target_type.dart';
class SessionListItem extends StatelessWidget {
final Session session;
@@ -25,130 +19,178 @@ class SessionListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
final isDark = Theme.of(context).brightness == Brightness.dark;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
final textMuted = isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted;
// Calcul de la couleur du score selon la moyenne par tir
final primaryColor = Theme.of(context).colorScheme.primary;
final avg = session.averageScore;
Color scoreColor = primaryColor;
if (avg >= 9.0) {
scoreColor = AppTheme.secondaryColor;
} else if (avg >= 7.5) {
scoreColor = primaryColor;
} else {
scoreColor = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
}
final formattedDate = DateFormat('dd/MM/yyyy • HH:mm', 'fr_FR').format(session.createdAt);
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: scoreColor,
borderColor: isDark ? scoreColor.withValues(alpha: 0.2) : scoreColor.withValues(alpha: 0.15),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Thumbnail
// Aperçu de la cible avec bordure nette
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 60,
height: 60,
child: _buildThumbnail(),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
const SizedBox(width: 12),
child: _buildThumbnail(isDark),
),
),
const SizedBox(width: 14),
// Info
// Informations de la session
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getTargetIcon(),
size: 16,
color: AppTheme.primaryColor,
),
const SizedBox(width: 4),
Text(
session.targetType.displayName,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 4),
Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
if (session.notes != null && session.notes!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
session.notes!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
session.weapon,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
formattedDate,
style: TextStyle(
fontSize: 11,
color: textSecondary,
),
),
const SizedBox(height: 5),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${session.distance}m',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
),
const SizedBox(width: 6),
Text(
'${session.targetCount} cible${session.targetCount > 1 ? 's' : ''}${session.totalShots} tirs',
style: TextStyle(
fontSize: 11,
color: textMuted,
),
),
],
),
],
),
),
// Score
Column(
// Score et moyenne stylisés façon cyber HUD
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scoreColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: scoreColor.withValues(alpha: isDark ? 0.3 : 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: scoreColor,
letterSpacing: -0.5,
),
),
Text(
'${session.shotCount} tirs',
style: Theme.of(context).textTheme.bodySmall,
'Moy. ${session.averageScore.toStringAsFixed(1)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: scoreColor,
),
),
],
),
),
// Delete button
if (onDelete != null)
// Bouton supprimer
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.delete_outline),
icon: const Icon(Icons.delete_outline_rounded),
onPressed: onDelete,
color: Colors.grey,
color: textMuted,
iconSize: 20,
tooltip: 'Supprimer',
),
],
),
),
],
),
);
}
Widget _buildThumbnail() {
final file = File(session.imagePath);
Widget _buildThumbnail(bool isDark) {
if (session.analyses.isEmpty) return _buildPlaceholder(isDark);
final file = File(session.analyses.first.imagePath);
if (file.existsSync()) {
return Image.file(
file,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildPlaceholder(),
errorBuilder: (_, _, _) => _buildPlaceholder(isDark),
);
}
return _buildPlaceholder();
return _buildPlaceholder(isDark);
}
Widget _buildPlaceholder() {
return Container(
color: Colors.grey[200],
child: Icon(
_getTargetIcon(),
color: Colors.grey[400],
),
Widget _buildPlaceholder(bool isDark) {
return Icon(
Icons.track_changes,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
size: 24,
);
}
IconData _getTargetIcon() {
switch (session.targetType) {
case TargetType.concentric:
return Icons.track_changes;
case TargetType.silhouette:
return Icons.person;
}
}
}
File diff suppressed because it is too large Load Diff
+68 -22
View File
@@ -1,17 +1,14 @@
/// Widget carte réutilisable pour afficher une statistique.
///
/// Affiche une icône, un titre et une valeur avec une couleur personnalisable.
/// Utilisé sur l'écran d'accueil pour les statistiques rapides.
library;
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
/// Widget carte télémétrique moderne avec effet Glassmorphism givré.
class StatsCard extends StatelessWidget {
final IconData icon;
final String title;
final String value;
final Color color;
final String? subtitle;
const StatsCard({
super.key,
@@ -19,33 +16,82 @@ class StatsCard extends StatelessWidget {
required this.title,
required this.value,
required this.color,
this.subtitle,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
final isDark = Theme.of(context).brightness == Brightness.dark;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: color,
borderColor: isDark ? color.withValues(alpha: 0.22) : color.withValues(alpha: 0.18),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: color,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 1,
),
),
const SizedBox(height: 4),
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
child: Icon(icon, color: color, size: 20),
),
if (subtitle != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
),
child: Text(
subtitle!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: textSecondary,
),
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
letterSpacing: -0.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textSecondary,
letterSpacing: 0.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
@@ -0,0 +1,60 @@
import 'package:flutter/foundation.dart';
import '../../data/models/target_analysis.dart';
class SessionProvider extends ChangeNotifier {
DateTime? _sessionDate;
DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName;
String? _currentWeaponId;
String? _currentWeaponCaliber;
int _shotsPerTarget = 5;
int _distance = 25;
String? _activeSessionId;
final List<TargetAnalysis> _currentAnalyses = [];
bool _isSessionActive = false;
String? get currentWeapon => _currentWeaponName;
String? get currentWeaponId => _currentWeaponId;
/// Calibre de l'arme de la session. Contrairement au nom d'arme, il ne
/// designe pas l'utilisateur : c'est la seule donnee d'arme envoyee au
/// backend d'entrainement (le diametre des trous en depend).
String? get currentWeaponCaliber => _currentWeaponCaliber;
int get shotsPerTarget => _shotsPerTarget;
int get distance => _distance;
bool get isSessionActive => _isSessionActive;
String? get activeSessionId => _activeSessionId;
List<TargetAnalysis> get currentAnalyses => List.unmodifiable(_currentAnalyses);
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, String? caliber, int distance = 25, DateTime? date}) {
_currentWeaponName = weaponName;
_currentWeaponId = weaponId;
_currentWeaponCaliber = caliber;
_shotsPerTarget = shots;
_distance = distance;
_sessionDate = date ?? DateTime.now();
_activeSessionId = sessionId;
_currentAnalyses.clear();
_isSessionActive = true;
notifyListeners();
}
void addAnalysis(TargetAnalysis analysis) {
_currentAnalyses.add(analysis);
notifyListeners();
}
void endSession() {
_isSessionActive = false;
_activeSessionId = null;
_currentWeaponId = null;
_currentWeaponName = null;
_currentWeaponCaliber = null;
_currentAnalyses.clear();
_distance = 25;
notifyListeners();
}
}
@@ -0,0 +1,536 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'session_provider.dart';
import '../capture/capture_screen.dart';
import '../garage/weapon_list_screen.dart';
class SessionSetupScreen extends StatefulWidget {
const SessionSetupScreen({super.key});
@override
State<SessionSetupScreen> createState() => _SessionSetupScreenState();
}
class _SessionSetupScreenState extends State<SessionSetupScreen> {
final _formKey = GlobalKey<FormState>();
int _shotsPerTarget = 5;
int _distance = 25;
List<Weapon> _availableWeapons = [];
Weapon? _selectedWeapon;
bool _isLoadingWeapons = true;
DateTime _selectedDate = DateTime.now();
/// Valeur sentinelle de l'item "Ajouter une nouvelle arme" du menu deroulant.
static const String _addWeaponValue = '__add_weapon__';
final GlobalKey<FormFieldState<String>> _weaponFieldKey =
GlobalKey<FormFieldState<String>>();
static const List<int> _presetDistances = [10, 15, 25, 50, 100, 200];
static const List<int> _presetShots = [3, 5, 10, 15, 20, 30, 50];
@override
void initState() {
super.initState();
_loadWeapons();
}
Future<void> _loadWeapons() async {
setState(() => _isLoadingWeapons = true);
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
if (mounted) {
setState(() {
_availableWeapons = weapons;
// Les armes rechargees sont de nouvelles instances (Weapon n'a pas
// d'operator ==) et l'arme selectionnee a pu etre supprimee : on
// re-resout la selection par identifiant.
final previousId = _selectedWeapon?.id;
_selectedWeapon = null;
for (final weapon in weapons) {
if (weapon.id == previousId) {
_selectedWeapon = weapon;
break;
}
}
if (_selectedWeapon == null && weapons.isNotEmpty) {
_selectedWeapon = weapons.first;
_updateSettingsForWeapon(_selectedWeapon!);
}
_isLoadingWeapons = false;
});
}
}
Future<void> _openArmory() async {
final knownIds = _availableWeapons.map((w) => w.id).toSet();
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
);
if (!mounted) return;
await _loadWeapons();
if (!mounted) return;
// Selectionne automatiquement l'arme qui vient d'etre ajoutee.
Weapon? added;
for (final weapon in _availableWeapons) {
if (!knownIds.contains(weapon.id)) {
added = weapon;
break;
}
}
if (added != null) {
final newWeapon = added;
setState(() {
_selectedWeapon = newWeapon;
_updateSettingsForWeapon(newWeapon);
});
}
}
void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
}
Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'),
);
if (picked != null && picked != _selectedDate) {
setState(() {
final now = DateTime.now();
_selectedDate = DateTime(
picked.year,
picked.month,
picked.day,
now.hour,
now.minute,
);
});
}
}
void _startSession() {
if (_formKey.currentState!.validate() && _selectedWeapon != null) {
final repository = context.read<SessionRepository>();
final sessionProvider = context.read<SessionProvider>();
final sessionId = repository.generateId();
sessionProvider.startSession(
_selectedWeapon!.displayName,
_shotsPerTarget,
sessionId,
weaponId: _selectedWeapon!.id,
caliber: _selectedWeapon!.caliber,
distance: _distance,
date: _selectedDate,
);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final formattedDate = DateFormat('dd MMMM yyyy • HH:mm', 'fr_FR').format(_selectedDate);
return Scaffold(
appBar: AppBar(
title: const Text('Configuration de Session'),
),
body: _isLoadingWeapons
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_availableWeapons.isEmpty)
_buildEmptyArmoryState(isDark)
else ...[
_buildWeaponAndDateSection(isDark, formattedDate),
const SizedBox(height: 20),
_buildDistanceSection(isDark),
const SizedBox(height: 20),
_buildShotsSection(isDark),
const SizedBox(height: 32),
_buildStartButton(),
const SizedBox(height: 16),
],
],
),
),
),
);
}
Widget _buildEmptyArmoryState(bool isDark) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.errorColor.withValues(alpha: 0.5),
),
),
child: Column(
children: [
const Icon(
Icons.warning_amber_rounded,
color: AppTheme.errorColor,
size: 52,
),
const SizedBox(height: 12),
Text(
'Armurerie vide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 6),
Text(
'Ajoutez au moins une arme dans votre armurerie pour débuter une séance.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: _openArmory,
icon: const Icon(Icons.shield),
label: const Text('Aller à l\'armurerie'),
),
],
),
);
}
Widget _buildWeaponAndDateSection(bool isDark, String formattedDate) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.tune, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Arme & Date',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: _weaponFieldKey,
initialValue: _selectedWeapon?.id,
decoration: const InputDecoration(
labelText: 'Arme utilisée',
prefixIcon: Icon(Icons.shield_outlined),
),
items: [
..._availableWeapons.map(
(w) => DropdownMenuItem(
value: w.id,
child: Text('${w.displayName} (${w.caliber})'),
),
),
DropdownMenuItem(
value: _addWeaponValue,
child: Row(
children: [
Icon(
Icons.add_circle_outline,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Ajouter une nouvelle arme',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
],
onChanged: (value) {
if (value == null) return;
if (value == _addWeaponValue) {
// L'item d'action n'est pas une arme : on restaure aussitot
// la selection precedente avant d'ouvrir l'armurerie.
_weaponFieldKey.currentState?.didChange(_selectedWeapon?.id);
_openArmory();
return;
}
for (final weapon in _availableWeapons) {
if (weapon.id == value) {
setState(() {
_selectedWeapon = weapon;
_updateSettingsForWeapon(weapon);
});
break;
}
}
},
),
const SizedBox(height: 12),
InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(12),
child: IgnorePointer(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Date de séance',
prefixIcon: Icon(Icons.calendar_today_outlined),
),
controller: TextEditingController(text: formattedDate),
),
),
),
],
),
),
);
}
Widget _buildDistanceSection(bool isDark) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.straighten, color: AppTheme.secondaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Distance de Tir',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.secondaryColor.withValues(alpha: 0.4),
),
),
child: Text(
'${_distance}m',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppTheme.secondaryColor,
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presetDistances.map((d) {
final isSelected = _distance == d;
return ChoiceChip(
label: Text('${d}m'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _distance = d);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
activeColor: AppTheme.secondaryColor,
onChanged: (val) {
setState(() => _distance = val.round());
},
),
],
),
),
);
}
Widget _buildShotsSection(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.ads_click, color: primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Tirs par Cible',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: primaryColor.withValues(alpha: 0.4),
),
),
child: Text(
'$_shotsPerTarget tirs',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presetShots.map((s) {
final isSelected = _shotsPerTarget == s;
return ChoiceChip(
label: Text('$s coups'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _shotsPerTarget = s);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
activeColor: primaryColor,
onChanged: (val) {
setState(() => _shotsPerTarget = val.round());
},
),
],
),
),
);
}
Widget _buildStartButton() {
final primaryColor = Theme.of(context).colorScheme.primary;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
primaryColor,
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0)).toColor(),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 26),
label: const Text(
'DÉMARRER LA SESSION',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -10
View File
@@ -5,11 +5,11 @@ import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'app.dart';
import 'core/theme/theme_provider.dart';
import 'data/repositories/session_repository.dart';
import 'services/target_detection_service.dart';
import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart';
import 'services/image_processing_service.dart';
import 'features/session/session_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -30,14 +30,6 @@ void main() async {
runApp(
MultiProvider(
providers: [
Provider<ImageProcessingService>(
create: (_) => ImageProcessingService(),
),
Provider<TargetDetectionService>(
create: (context) => TargetDetectionService(
imageProcessingService: context.read<ImageProcessingService>(),
),
),
Provider<ScoreCalculatorService>(
create: (_) => ScoreCalculatorService(),
),
@@ -45,6 +37,8 @@ void main() async {
create: (_) => GroupingAnalyzerService(),
),
Provider<SessionRepository>(create: (_) => SessionRepository()),
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
],
child: const BullyApp(),
),
+194
View File
@@ -0,0 +1,194 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'features/home/home_screen.dart';
import 'features/history/history_screen.dart';
import 'features/statistics/statistics_screen.dart';
import 'features/garage/weapon_list_screen.dart';
import 'core/theme/app_theme.dart';
/// 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.
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});
@override
State<MainNavigationHolder> createState() => _MainNavigationHolderState();
}
class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0;
int _statsTick = 0;
int _historyTick = 0;
int _homeTick = 0;
int _garageTick = 0;
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 isDark = Theme.of(context).brightness == Brightness.dark;
final screens = [
HomeScreen(refreshTick: _homeTick),
HistoryScreen(refreshTick: _historyTick),
StatisticsScreen(refreshTick: _statsTick),
WeaponListScreen(refreshTick: _garageTick),
];
return Scaffold(
extendBody: true,
body: IndexedStack(
index: _selectedIndex,
children: screens,
),
bottomNavigationBar: _buildFloatingGlassDock(isDark),
);
}
Widget _buildFloatingGlassDock(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
final navItems = [
(Icons.radar_outlined, Icons.radar, 'Accueil'),
(Icons.history_outlined, Icons.history_rounded, 'Historique'),
(Icons.insights_outlined, Icons.insights_rounded, 'Stats'),
(Icons.shield_outlined, Icons.shield, 'Armurerie'),
];
return SafeArea(
child: Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
height: 68,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
blurRadius: 24,
offset: const Offset(0, 8),
),
if (isDark)
BoxShadow(
color: primaryColor.withValues(alpha: 0.12),
blurRadius: 16,
spreadRadius: -4,
offset: const Offset(0, -2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: isDark
? const Color(0xFF101722).withValues(alpha: 0.78)
: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.14)
: Colors.black.withValues(alpha: 0.08),
width: 1.2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(navItems.length, (index) {
final item = navItems[index];
final isSelected = _selectedIndex == index;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => selectTab(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: isSelected
? primaryColor.withValues(
alpha: isDark ? 0.22 : 0.15,
)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: isSelected
? Border.all(
color: primaryColor.withValues(
alpha: isDark ? 0.45 : 0.35,
),
width: 1,
)
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? item.$2 : item.$1,
size: 22,
color: isSelected
? primaryColor
: (isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary),
),
const SizedBox(height: 2),
Text(
item.$3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.w700
: FontWeight.w500,
color: isSelected
? primaryColor
: (isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary),
letterSpacing: 0.2,
),
),
],
),
),
),
);
}),
),
),
),
),
),
);
}
}
+243
View File
@@ -0,0 +1,243 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:image/image.dart' as img;
import 'package:crypto/crypto.dart';
import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import 'wallet_identity_service.dart';
/// 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 {
/// Retire toutes les metadonnees de la photo avant l'envoi.
///
/// Une photo de cible prise au telephone embarque un bloc EXIF qui contient
/// typiquement la position GPS du stand de tir, la date exacte et le modele
/// d'appareil. Rien de tout cela n'est utile au modele de detection.
///
/// L'orientation est d'abord appliquee physiquement aux pixels : les
/// coordonnees d'impact sont normalisees sur l'image telle qu'elle est
/// affichee dans l'app (Flutter applique l'orientation EXIF), donc supprimer
/// le tag sans redresser l'image ferait pivoter la photo par rapport a ses
/// propres annotations.
///
/// Retourne null si l'image est illisible.
@visibleForTesting
Uint8List? stripMetadata(Uint8List originalBytes) {
try {
final decoded = img.decodeImage(originalBytes);
if (decoded == null) return null;
final baked = img.bakeOrientation(decoded);
// bakeOrientation recopie tout l'EXIF sauf l'orientation : sans ce reset,
// le GPS survivrait au reencodage.
baked.exif = img.ExifData();
return img.encodeJpg(baked, quality: 90);
} catch (e) {
// Sur un fichier tronque, decodeImage leve au lieu de retourner null.
debugPrint('Photo illisible, export annule: $e');
return null;
}
}
/// Exporte l'image et les données de plotting vers le serveur
Future<AiExportResult> exportData({
required String imagePath,
required String sessionId,
required TargetType targetType,
required double targetCenterX,
required double targetCenterY,
required double targetRadius,
required List<Shot> shots,
int distanceMeters = 25,
String caliber = 'unknown',
String? apiUrl,
}) async {
try {
final walletService = WalletIdentityService();
final rawBaseUrl = await walletService.getServerBaseUrl();
final baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.substring(0, rawBaseUrl.length - 1) : rawBaseUrl;
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
final url = Uri.parse(effectiveUrl);
final request = http.MultipartRequest('POST', url);
request.headers['X-API-KEY'] = WalletIdentityService.apiKey;
// 1. Prepare image
final file = File(imagePath);
if (!await file.exists()) {
return AiExportResult.error(
code: 'FILE_NOT_FOUND',
message: 'Le fichier image cible est introuvable.',
);
}
final sanitizedPhoto = stripMetadata(await file.readAsBytes());
if (sanitizedPhoto == null) {
return AiExportResult.error(
code: 'INVALID_IMAGE',
message: 'La photo de la cible est illisible et n\'a pas pu être envoyée.',
);
}
final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY + targetRadius},
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY + targetRadius},
];
// Format the impacts
final formattedImpacts = shots.asMap().entries.map((entry) {
final index = entry.key;
final shot = entry.value;
return {
"id": index + 1,
"label": "bullet_hole",
"score": shot.score,
"coords": {
"norm_x": shot.x,
"norm_y": shot.y
}
};
}).toList();
// Get and hash the wallet identity
final phrase = await walletService.getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
// Build JSON payload
final plottingJson = {
"session_id": sessionId,
"wallet_hash": walletHash,
"timestamp": DateTime.now().toIso8601String(),
"target_metadata": {
"type": targetType.name,
"distance_meters": distanceMeters,
"caliber": caliber,
},
"plotting": {
"target_corners": corners,
"impacts": formattedImpacts
}
};
request.fields['plotting'] = jsonEncode(plottingJson);
request.files.add(
http.MultipartFile.fromBytes(
'photo',
sanitizedPhoto,
filename: 'target.jpg',
),
);
final streamedResponse = await request.send().timeout(
const Duration(seconds: 15),
onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
);
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 {
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 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);
}
}

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