Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e27d01f17 | ||
|
|
8dd78f6b80 | ||
|
|
23c3bb178f | ||
|
|
18e591f3fc | ||
|
|
13cf5b70e0 |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(flutter analyze:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(flutter clean:*)",
|
||||||
|
"Bash(flutter pub get:*)",
|
||||||
|
"Bash(flutter run:*)",
|
||||||
|
"Bash(cmake:*)",
|
||||||
|
"Bash(where:*)",
|
||||||
|
"Bash(winget search:*)",
|
||||||
|
"Bash(winget install:*)",
|
||||||
|
"Bash(\"/c/Program Files \\(x86\\)/Microsoft Visual Studio/Installer/vs_installer.exe\" modify --installPath \"C:\\\\Program Files \\(x86\\)\\\\Microsoft Visual Studio\\\\2022\\\\BuildTools\" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait)",
|
||||||
|
"Bash(cmd //c \"\"\"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\18\\\\Community\\\\Common7\\\\Tools\\\\VsDevCmd.bat\"\" && flutter run -d windows\")",
|
||||||
|
"Bash(flutter doctor:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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."
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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 !"
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
.svn/
|
.svn/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
.claude/
|
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
@@ -1,40 +1,5 @@
|
|||||||
# Changelog
|
# 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
|
## [v0.0.1] - 2026-01-29
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -41,12 +41,10 @@ flutter test --coverage
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Capture de cibles de tir
|
### Analyse de cibles de tir
|
||||||
- Support de cibles concentriques (anneaux) et silhouettes
|
- Support de cibles concentriques (anneaux) et silhouettes
|
||||||
- Chargement d'images de cibles depuis la galerie ou la caméra
|
- Chargement d'images de cibles depuis la galerie ou la caméra
|
||||||
- Aperçu caméra avec aide au cadrage : détection OpenCV de la cible (mire)
|
- Détection automatique du centre et du rayon de la cible
|
||||||
et indicateur de parallélisme par accéléromètre (pitch/roll)
|
|
||||||
- Écran de centrage/recadrage : rotation fine, déplacement pixel par pixel
|
|
||||||
|
|
||||||
### Calibration des cibles
|
### Calibration des cibles
|
||||||
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
|
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
|
||||||
@@ -54,13 +52,14 @@ flutter test --coverage
|
|||||||
- Slider global pour redimensionner tous les anneaux proportionnellement
|
- Slider global pour redimensionner tous les anneaux proportionnellement
|
||||||
- Visualisation en temps réel des zones de score
|
- Visualisation en temps réel des zones de score
|
||||||
|
|
||||||
### Placement des impacts
|
### Détection d'impacts
|
||||||
- **Éditeur d'impacts plein écran** : tap pour ajouter (y compris juste à côté
|
- **Ajout manuel** : cliquer sur l'image pour placer un impact
|
||||||
ou par-dessus un impact existant), appui long pour déplacer, pincer pour zoomer
|
- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables
|
||||||
- Un tap n'ouvre jamais d'édition de score : le score reste calculé
|
- Seuil de luminosité
|
||||||
automatiquement d'après la position de l'impact
|
- Taille min/max des impacts
|
||||||
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de synthèse
|
- Circularité minimale
|
||||||
efface tous les impacts sans toucher à la calibration
|
- 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
|
||||||
|
|
||||||
### Calcul des scores
|
### Calcul des scores
|
||||||
- Score automatique basé sur la position de l'impact dans les zones
|
- Score automatique basé sur la position de l'impact dans les zones
|
||||||
@@ -80,19 +79,6 @@ flutter test --coverage
|
|||||||
- **Distribution régionale** : répartition des tirs par quadrant
|
- **Distribution régionale** : répartition des tirs par quadrant
|
||||||
- Filtrage par période : session, semaine, mois, toutes les sessions
|
- Filtrage par période : session, semaine, mois, toutes les sessions
|
||||||
|
|
||||||
### Sauvegarde (export / import JSON)
|
|
||||||
- Bouton **Exporter** en bas de l'écran Statistiques : génère un fichier JSON
|
|
||||||
(sessions + cibles + impacts + calibration, armurerie + entretien, et un
|
|
||||||
instantané des statistiques calculées) puis ouvre la feuille de partage du
|
|
||||||
système (`share_plus`) pour l'envoyer où l'on veut
|
|
||||||
- Option « Inclure les photos des cibles » : photos encodées en base64 dans le
|
|
||||||
JSON (sauvegarde complète mais fichier lourd) ; sans elles le fichier reste léger
|
|
||||||
- Bouton **Importer** (`file_selector`) : aperçu du contenu avant confirmation,
|
|
||||||
puis fusion avec les données existantes — même identifiant = mise à jour,
|
|
||||||
donc réimporter deux fois ne crée pas de doublon et rien n'est effacé
|
|
||||||
- Les statistiques ne sont pas réimportées : elles sont recalculées à partir des
|
|
||||||
sessions. Elles figurent dans le fichier pour être exploitables telles quelles
|
|
||||||
|
|
||||||
### Historique des sessions
|
### Historique des sessions
|
||||||
- Sauvegarde des sessions avec date, score, notes
|
- Sauvegarde des sessions avec date, score, notes
|
||||||
- Visualisation des sessions passées
|
- Visualisation des sessions passées
|
||||||
@@ -134,4 +120,3 @@ session_list_item.dart Item de liste représentant une session
|
|||||||
history_chart.dart Graphique d'évolution des 10 dernières sessions
|
history_chart.dart Graphique d'évolution des 10 dernières sessions
|
||||||
statistics_screen.dart Écran statistiques avec filtrage par période
|
statistics_screen.dart Écran statistiques avec filtrage par période
|
||||||
heat_map_widget.dart Heat map avec gradient bleu→rouge
|
heat_map_widget.dart Heat map avec gradient bleu→rouge
|
||||||
backup_service.dart Export/import JSON des sessions, stats et armurerie
|
|
||||||
|
|||||||
@@ -7,15 +7,6 @@
|
|||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
analyzer:
|
|
||||||
exclude:
|
|
||||||
- build/**
|
|
||||||
- android/**
|
|
||||||
- ios/**
|
|
||||||
- web/**
|
|
||||||
- windows/**
|
|
||||||
- macos/**
|
|
||||||
- linux/**
|
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<!-- Pour Android 12 et inférieur -->
|
<!-- Pour Android 12 et inférieur -->
|
||||||
@@ -11,8 +9,7 @@
|
|||||||
<application
|
<application
|
||||||
android:label="bully"
|
android:label="bully"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher">
|
||||||
android:usesCleartextTraffic="true">
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -40,6 +37,9 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||||
|
android:value="docscanner" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
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
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,8 +0,0 @@
|
|||||||
node_modules
|
|
||||||
npm-debug.log*
|
|
||||||
dashboard/node_modules
|
|
||||||
dashboard/.next
|
|
||||||
uploads
|
|
||||||
exports
|
|
||||||
.git
|
|
||||||
.env*
|
|
||||||
@@ -13,7 +13,8 @@ dashboard/out/
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
# Database (contient des données utilisateurs — jamais dans git)
|
# Database
|
||||||
|
backendia/uploads/data/database.sqlite
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-journal
|
*.sqlite-journal
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ uploads/images/*
|
|||||||
!uploads/images/.gitkeep
|
!uploads/images/.gitkeep
|
||||||
uploads/data/*
|
uploads/data/*
|
||||||
!uploads/data/.gitkeep
|
!uploads/data/.gitkeep
|
||||||
|
!uploads/data/database.sqlite
|
||||||
exports/*
|
exports/*
|
||||||
!exports/.gitkeep
|
!exports/.gitkeep
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LayoutDashboard, Users, ScrollText, Image as ImageIcon } from "lucide-react";
|
import { LayoutDashboard, Users, Image as ImageIcon } from "lucide-react";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -41,13 +41,6 @@ export default function RootLayout({
|
|||||||
<Users size={20} />
|
<Users size={20} />
|
||||||
Contributeurs
|
Contributeurs
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto pt-6 border-t border-slate-800">
|
<div className="mt-auto pt-6 border-t border-slate-800">
|
||||||
|
|||||||
@@ -1,900 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { fetchApi } from "@/lib/api";
|
|
||||||
import LogsManager from "./LogsManager";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Logs d'Uploads | Bully IA Dashboard",
|
|
||||||
description: "Journal complet des téléversements de sessions et métadonnées",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function LogsPage() {
|
|
||||||
let logs = [];
|
|
||||||
let stats = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [logsRes, statsRes] = await Promise.all([
|
|
||||||
fetchApi('/api/logs?limit=100'),
|
|
||||||
fetchApi('/api/logs/stats')
|
|
||||||
]);
|
|
||||||
logs = logsRes.logs || [];
|
|
||||||
stats = statsRes.stats || null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Erreur chargement initial des logs:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LogsManager
|
|
||||||
initialLogs={logs}
|
|
||||||
initialStats={stats}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchApi } from "@/lib/api";
|
import { fetchApi, API_BASE_URL } from "@/lib/api";
|
||||||
import DatasetToolbar from "@/components/DatasetToolbar";
|
import DatasetToolbar from "@/components/DatasetToolbar";
|
||||||
import { Calendar, User, Crosshair } from "lucide-react";
|
import { Calendar, User, Crosshair } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -49,7 +49,7 @@ export default async function DashboardPage() {
|
|||||||
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
|
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={photo.imageUrl}
|
src={`${API_BASE_URL}${photo.imageUrl}`}
|
||||||
alt={photo.filename}
|
alt={photo.filename}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchApi } from "@/lib/api";
|
import { fetchApi, API_BASE_URL } from "@/lib/api";
|
||||||
import PhotoEditor from "@/components/PhotoEditor";
|
import PhotoEditor from "@/components/PhotoEditor";
|
||||||
import { ChevronLeft, Download } from "lucide-react";
|
import { ChevronLeft, Download } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -33,7 +33,7 @@ export default async function PhotoDetailPage({ params }: { params: Promise<{ id
|
|||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/uploads/images/${id}`}
|
href={`${API_BASE_URL}/uploads/images/${id}`}
|
||||||
download
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function PhotoEditor({ initialPhoto }: PhotoEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PhotoOverlay
|
<PhotoOverlay
|
||||||
imageUrl={initialPhoto.imageUrl}
|
imageUrl={`${API_BASE_URL}${initialPhoto.imageUrl}`}
|
||||||
impacts={impacts}
|
impacts={impacts}
|
||||||
targetCorners={photoData?.plotting?.target_corners || []}
|
targetCorners={photoData?.plotting?.target_corners || []}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
const isServer = typeof window === 'undefined';
|
export const API_BASE_URL = 'http://127.0.0.1:3000';
|
||||||
export const API_BASE_URL = isServer
|
|
||||||
? `http://127.0.0.1:${process.env.PORT || 3000}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
export async function fetchApi(endpoint: string) {
|
export async function fetchApi(endpoint: string) {
|
||||||
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777452202935",
|
||||||
|
"timestamp": "2026-04-29T08:43:22.943303",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.3934941044560185,
|
||||||
|
"norm_y": 0.665676540798611
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6055302372685185,
|
||||||
|
"norm_y": 0.6953124999999999
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5981137876157407,
|
||||||
|
"norm_y": 0.5249565972222221
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6759033203125,
|
||||||
|
"norm_y": 0.4786376953125
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.48608398437499994,
|
||||||
|
"norm_y": 0.5490315755208333
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5101625795717593,
|
||||||
|
"norm_y": 0.43238661024305547
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6296079282407407,
|
||||||
|
"norm_y": 0.4203152126736111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.46386718749999994,
|
||||||
|
"norm_y": 0.48046875
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.3379521122685185,
|
||||||
|
"norm_y": 0.4397786458333333
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777452481252",
|
||||||
|
"timestamp": "2026-04-29T08:48:01.259705",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.3934941044560185,
|
||||||
|
"norm_y": 0.665676540798611
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6055302372685185,
|
||||||
|
"norm_y": 0.6953124999999999
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5981137876157407,
|
||||||
|
"norm_y": 0.5249565972222221
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6759033203125,
|
||||||
|
"norm_y": 0.4786376953125
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.48608398437499994,
|
||||||
|
"norm_y": 0.5490315755208333
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5101625795717593,
|
||||||
|
"norm_y": 0.43238661024305547
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6296079282407407,
|
||||||
|
"norm_y": 0.4203152126736111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.46386718749999994,
|
||||||
|
"norm_y": 0.48046875
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.3379521122685185,
|
||||||
|
"norm_y": 0.4397786458333333
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777452613625",
|
||||||
|
"timestamp": "2026-04-29T08:50:13.631206",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 10,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.49810836226851846,
|
||||||
|
"norm_y": 0.4981011284722222
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777463308809",
|
||||||
|
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||||
|
"timestamp": "2026-04-29T11:48:28.834236",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.38424660011574074,
|
||||||
|
"norm_y": 0.3666042751736111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6055302372685185,
|
||||||
|
"norm_y": 0.396240234375
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5860903139467593,
|
||||||
|
"norm_y": 0.5999620225694443
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.33517433449074074,
|
||||||
|
"norm_y": 0.5592041015625
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6073612919560185,
|
||||||
|
"norm_y": 0.5101047092013888
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.432373046875,
|
||||||
|
"norm_y": 0.46663411458333337
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.45181297019675926,
|
||||||
|
"norm_y": 0.5564236111111112
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 10,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5221860532407407,
|
||||||
|
"norm_y": 0.48792860243055547
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6564335575810184,
|
||||||
|
"norm_y": 0.7536349826388888
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.4712528935185185,
|
||||||
|
"norm_y": 0.7018229166666666
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.3981327763310185,
|
||||||
|
"norm_y": 0.7018229166666666
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.48144531249999994,
|
||||||
|
"norm_y": 0.8212483723958334
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777463703756",
|
||||||
|
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||||
|
"timestamp": "2026-04-29T11:55:03.765285",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.38424660011574074,
|
||||||
|
"norm_y": 0.45178222656249994
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6518247251157407,
|
||||||
|
"norm_y": 0.3638237847222222
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5490415219907407,
|
||||||
|
"norm_y": 0.66845703125
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.405517578125,
|
||||||
|
"norm_y": 0.6758490668402777
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7268066406249999,
|
||||||
|
"norm_y": 0.6758490668402777
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5471806278935185,
|
||||||
|
"norm_y": 0.529568142361111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.44720504195601846,
|
||||||
|
"norm_y": 0.5980631510416666
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.45458984375,
|
||||||
|
"norm_y": 0.4221462673611111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5203251591435185,
|
||||||
|
"norm_y": 0.3638237847222222
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777464156234",
|
||||||
|
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||||
|
"timestamp": "2026-04-29T12:02:36.255849",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.38424660011574074,
|
||||||
|
"norm_y": 0.45178222656249994
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6518247251157407,
|
||||||
|
"norm_y": 0.3638237847222222
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5490415219907407,
|
||||||
|
"norm_y": 0.66845703125
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.405517578125,
|
||||||
|
"norm_y": 0.6758490668402777
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7268066406249999,
|
||||||
|
"norm_y": 0.6758490668402777
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5471806278935185,
|
||||||
|
"norm_y": 0.529568142361111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.44720504195601846,
|
||||||
|
"norm_y": 0.5980631510416666
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.45458984375,
|
||||||
|
"norm_y": 0.4221462673611111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5203251591435185,
|
||||||
|
"norm_y": 0.3638237847222222
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777464607256",
|
||||||
|
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||||
|
"timestamp": "2026-04-29T12:10:07.260954",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.31570457175925926,
|
||||||
|
"norm_y": 0.7388509114583333
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.3184814453125,
|
||||||
|
"norm_y": 0.6489935980902778
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.3110966435185185,
|
||||||
|
"norm_y": 0.529568142361111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.31570457175925926,
|
||||||
|
"norm_y": 0.4416097005208333
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 4,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.31570457175925926,
|
||||||
|
"norm_y": 0.34252929687499994
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.32589699074074074,
|
||||||
|
"norm_y": 0.2332763671875
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.45458984375,
|
||||||
|
"norm_y": 0.19624837239583331
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5981137876157407,
|
||||||
|
"norm_y": 0.23049587673611108
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6907045717592593,
|
||||||
|
"norm_y": 0.3546006944444444
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.699951171875,
|
||||||
|
"norm_y": 0.48046875
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6268310546874999,
|
||||||
|
"norm_y": 0.5416395399305556
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.49072265624999994,
|
||||||
|
"norm_y": 0.5610351562499999
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.4203495732060185,
|
||||||
|
"norm_y": 0.536960177951389
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 6,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5370180483217593,
|
||||||
|
"norm_y": 0.6879204644097222
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6175537109375,
|
||||||
|
"norm_y": 0.7601453993055556
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 1,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7027280454282406,
|
||||||
|
"norm_y": 0.8406439887152777
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1 @@
|
|||||||
|
# Keep directory structure
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777553535029",
|
||||||
|
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||||
|
"timestamp": "2026-04-30T12:52:15.048830",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 0,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.17681929976851848,
|
||||||
|
"norm_y": 0.8851318359375
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 0,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.17681929976851848,
|
||||||
|
"norm_y": 0.7721489800347222
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 1,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.17959617332175923,
|
||||||
|
"norm_y": 0.6888020833333334
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.17959617332175923,
|
||||||
|
"norm_y": 0.6342095269097222
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.17959617332175923,
|
||||||
|
"norm_y": 0.560153537326389
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.17959617332175923,
|
||||||
|
"norm_y": 0.5138346354166666
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.1814579716435185,
|
||||||
|
"norm_y": 0.45273166232638884
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.18701171875,
|
||||||
|
"norm_y": 0.3878987630208333
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 1,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.18887351707175926,
|
||||||
|
"norm_y": 0.2925482855902778
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 0,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.2036747685185185,
|
||||||
|
"norm_y": 0.20085991753472218
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 0,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.27035499855324074,
|
||||||
|
"norm_y": 0.16200086805555558
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 1,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.37588523582175926,
|
||||||
|
"norm_y": 0.1546088324652778
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.46292136863425926,
|
||||||
|
"norm_y": 0.1518283420138889
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 1,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6814579716435185,
|
||||||
|
"norm_y": 0.1499294704861111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5509033203125,
|
||||||
|
"norm_y": 0.1546088324652778
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 1,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6156919126157407,
|
||||||
|
"norm_y": 0.1546088324652778
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 1,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7703251591435185,
|
||||||
|
"norm_y": 0.2360568576388889
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 4,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7110595703125,
|
||||||
|
"norm_y": 0.3536512586805555
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6027524594907407,
|
||||||
|
"norm_y": 0.4129231770833333
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.4823911313657407,
|
||||||
|
"norm_y": 0.4304877387152777
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.39627097800925926,
|
||||||
|
"norm_y": 0.43509928385416663
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.289794921875,
|
||||||
|
"norm_y": 0.43238661024305547
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 2,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7166440610532407,
|
||||||
|
"norm_y": 0.7693684895833333
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 3,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7314453125,
|
||||||
|
"norm_y": 0.7008734809027778
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 1,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7879331235532407,
|
||||||
|
"norm_y": 0.7601453993055556
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 0,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.736083984375,
|
||||||
|
"norm_y": 0.828640407986111
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Keep directory structure
|
||||||
|
After Width: | Height: | Size: 105 KiB |
@@ -9,526 +9,14 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@techstark/opencv-js": "^5.0.0-release.1",
|
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"sharp": "^0.35.3",
|
|
||||||
"sqlite3": "^6.0.1"
|
"sqlite3": "^6.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.11.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
|
||||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/colour": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@img/sharp-wasm32": "0.35.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-wasm32": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/runtime": "^1.11.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
|
||||||
"cpu": [
|
|
||||||
"wasm32"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@img/sharp-wasm32": "0.35.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@isaacs/fs-minipass": {
|
"node_modules/@isaacs/fs-minipass": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||||
@@ -541,12 +29,6 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@techstark/opencv-js": {
|
|
||||||
"version": "5.0.0-release.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz",
|
|
||||||
"integrity": "sha512-PIm+eB0MFtieXoNC2GRao0dv/02sehG+Nv2nSW5D6pQm6J/4WqvDHm0RyoqOmGYQm67jdGiaOdIeTShCY3PIUg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
|
||||||
@@ -1782,9 +1264,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.8.5",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -1844,55 +1326,6 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
|
||||||
"version": "0.35.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
|
||||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@img/colour": "^1.1.0",
|
|
||||||
"detect-libc": "^2.1.2",
|
|
||||||
"semver": "^7.8.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-darwin-arm64": "0.35.3",
|
|
||||||
"@img/sharp-darwin-x64": "0.35.3",
|
|
||||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
|
||||||
"@img/sharp-linux-arm": "0.35.3",
|
|
||||||
"@img/sharp-linux-arm64": "0.35.3",
|
|
||||||
"@img/sharp-linux-ppc64": "0.35.3",
|
|
||||||
"@img/sharp-linux-riscv64": "0.35.3",
|
|
||||||
"@img/sharp-linux-s390x": "0.35.3",
|
|
||||||
"@img/sharp-linux-x64": "0.35.3",
|
|
||||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
|
||||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
|
||||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
|
||||||
"@img/sharp-win32-arm64": "0.35.3",
|
|
||||||
"@img/sharp-win32-ia32": "0.35.3",
|
|
||||||
"@img/sharp-win32-x64": "0.35.3"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/node": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
@@ -2148,13 +1581,6 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|||||||
@@ -12,16 +12,11 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@techstark/opencv-js": "^5.0.0-release.1",
|
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"multer": "^2.1.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"
|
"sqlite3": "^6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ const sqlite3 = require('sqlite3').verbose();
|
|||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
|
|
||||||
|
|
||||||
const TargetValidator = require('./services/target_validator');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
@@ -36,128 +34,14 @@ const db = new sqlite3.Database(dbPath, (err) => {
|
|||||||
console.error('Erreur de connexion à SQLite:', err.message);
|
console.error('Erreur de connexion à SQLite:', err.message);
|
||||||
} else {
|
} else {
|
||||||
console.log('Connecté à la base de données SQLite.');
|
console.log('Connecté à la base de données SQLite.');
|
||||||
db.serialize(() => {
|
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
|
wallet_hash TEXT PRIMARY KEY,
|
||||||
wallet_hash TEXT PRIMARY KEY,
|
photo_count INTEGER DEFAULT 0,
|
||||||
photo_count INTEGER DEFAULT 0,
|
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
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
|
// Configuration de multer pour le stockage des fichiers
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
@@ -197,22 +81,10 @@ app.get('/api/health', (req, res) => {
|
|||||||
|
|
||||||
// Route pour l'upload de photo + données JSON
|
// Route pour l'upload de photo + données JSON
|
||||||
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
|
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
|
||||||
app.post('/api/upload', upload.single('photo'), async (req, res) => {
|
app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||||
const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || '';
|
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
logUploadEntry({
|
return res.status(400).json({ error: 'Aucune photo fournie' });
|
||||||
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 = {};
|
let plottingData = {};
|
||||||
@@ -221,75 +93,10 @@ app.post('/api/upload', upload.single('photo'), async (req, res) => {
|
|||||||
plottingData = JSON.parse(req.body.plotting);
|
plottingData = JSON.parse(req.body.plotting);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Erreur parsing JSON:", e);
|
console.error("Erreur parsing JSON:", e);
|
||||||
logUploadEntry({
|
return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' });
|
||||||
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
|
// Nom de base sans l'extension
|
||||||
const baseFilename = path.parse(req.file.filename).name;
|
const baseFilename = path.parse(req.file.filename).name;
|
||||||
const jsonFilename = `${baseFilename}.json`;
|
const jsonFilename = `${baseFilename}.json`;
|
||||||
@@ -298,13 +105,8 @@ app.post('/api/upload', upload.single('photo'), async (req, res) => {
|
|||||||
// Sauvegarde du JSON dans uploads/data/
|
// Sauvegarde du JSON dans uploads/data/
|
||||||
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
|
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
|
// Mise à jour de la BDD si on a un wallet_hash
|
||||||
|
const walletHash = plottingData.wallet_hash;
|
||||||
if (walletHash) {
|
if (walletHash) {
|
||||||
db.run(`
|
db.run(`
|
||||||
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
|
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
|
||||||
@@ -320,37 +122,12 @@ app.post('/api/upload', upload.single('photo'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enregistrement dans la table des logs d'upload avec diagnostic OpenCV
|
|
||||||
logUploadEntry({
|
|
||||||
sessionId,
|
|
||||||
walletHash,
|
|
||||||
imageFilename: req.file.filename,
|
|
||||||
jsonFilename,
|
|
||||||
fileSize: req.file.size,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
deviceModel: deviceInfo.model || null,
|
|
||||||
deviceOs: deviceInfo.os || null,
|
|
||||||
targetType: targetMeta.type || null,
|
|
||||||
weapon: targetMeta.weapon || null,
|
|
||||||
distanceMeters: targetMeta.distance_meters || null,
|
|
||||||
impactsCount,
|
|
||||||
status: 'SUCCESS',
|
|
||||||
targetValid: validation.isValid ? 1 : 0,
|
|
||||||
targetStatus: validation.status,
|
|
||||||
targetConfidence: validation.confidence,
|
|
||||||
targetRingsCount: validation.ringsCount,
|
|
||||||
targetDetails: validation.details,
|
|
||||||
rawMetadata: plottingData
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Données reçues et sauvegardées:`);
|
console.log(`Données reçues et sauvegardées:`);
|
||||||
console.log(`- Image: uploads/images/${req.file.filename}`);
|
console.log(`- Image: uploads/images/${req.file.filename}`);
|
||||||
console.log(`- JSON : uploads/data/${jsonFilename}`);
|
console.log(`- JSON : uploads/data/${jsonFilename}`);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
code: 'UPLOAD_SUCCESS',
|
|
||||||
message: 'Photo et données uploadées avec succès',
|
message: 'Photo et données uploadées avec succès',
|
||||||
file: {
|
file: {
|
||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
@@ -359,254 +136,14 @@ app.post('/api/upload', upload.single('photo'), async (req, res) => {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
filename: jsonFilename
|
filename: jsonFilename
|
||||||
},
|
}
|
||||||
target_validation: validation
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de l\'upload:', error);
|
console.error('Erreur lors de l\'upload:', error);
|
||||||
logUploadEntry({
|
res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' });
|
||||||
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
|
// Route pour récupérer toutes les photos disponibles
|
||||||
app.get('/api/photos', (req, res) => {
|
app.get('/api/photos', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -627,42 +164,20 @@ app.get('/api/photos', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route pour récupérer les statistiques d'un wallet_hash + statut de modération
|
// Route pour récupérer les statistiques d'un wallet_hash
|
||||||
app.get('/api/stats/:wallet_hash', (req, res) => {
|
app.get('/api/stats/:wallet_hash', (req, res) => {
|
||||||
const walletHash = req.params.wallet_hash;
|
const walletHash = req.params.wallet_hash;
|
||||||
|
|
||||||
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, statRow) => {
|
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
|
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) => {
|
if (row) {
|
||||||
const isBanned = !!bannedRow;
|
res.json({ status: 'ok', stats: row });
|
||||||
const stats = statRow || { photo_count: 0, last_upload: null };
|
} else {
|
||||||
res.json({
|
res.json({ status: 'ok', stats: { photo_count: 0, last_upload: null } });
|
||||||
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
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -818,40 +333,14 @@ app.use((err, req, res, next) => {
|
|||||||
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
|
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Intégration du Dashboard Next.js
|
// Démarrer le serveur
|
||||||
const dashboardDir = path.join(__dirname, 'dashboard');
|
app.listen(PORT, () => {
|
||||||
const dev = process.env.NODE_ENV !== 'production';
|
console.log(`=================================`);
|
||||||
|
console.log(`Serveur Backend IA démarré`);
|
||||||
let nextApp;
|
console.log(`Port: ${PORT}`);
|
||||||
try {
|
console.log(`Dossiers:`);
|
||||||
const next = require('next');
|
console.log(` - Images: ${imagesDir}`);
|
||||||
nextApp = next({ dev, dir: dashboardDir });
|
console.log(` - Data : ${dataDir}`);
|
||||||
} catch (e) {
|
console.log(` - Export: ${exportsDir}`);
|
||||||
console.warn("Module 'next' non disponible, mode API seule.");
|
console.log(`=================================`);
|
||||||
}
|
});
|
||||||
|
|
||||||
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();
|
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"session_id": "session_1777554006515",
|
||||||
|
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||||
|
"timestamp": "2026-04-30T13:00:06.519693",
|
||||||
|
"device_info": {
|
||||||
|
"model": "google sdk_gphone64_x86_64",
|
||||||
|
"os": "Android 16"
|
||||||
|
},
|
||||||
|
"target_metadata": {
|
||||||
|
"type": "concentric",
|
||||||
|
"distance_meters": 25,
|
||||||
|
"weapon": "Unknown"
|
||||||
|
},
|
||||||
|
"plotting": {
|
||||||
|
"target_corners": [
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.09999999999999998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.9,
|
||||||
|
"norm_y": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm_x": 0.09999999999999998,
|
||||||
|
"norm_y": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 5,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.267578125,
|
||||||
|
"norm_y": 0.5554741753472222
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 7,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5712583188657407,
|
||||||
|
"norm_y": 0.3592800564236111
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 4,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.7555239076967593,
|
||||||
|
"norm_y": 0.5703938802083333
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 4,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.6666259765625,
|
||||||
|
"norm_y": 0.6759168836805556
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 9,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.5490415219907407,
|
||||||
|
"norm_y": 0.5212944878472222
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"label": "bullet_hole",
|
||||||
|
"score": 8,
|
||||||
|
"coords": {
|
||||||
|
"norm_x": 0.4897768373842592,
|
||||||
|
"norm_y": 0.39352756076388895
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 107 KiB |
@@ -1,21 +0,0 @@
|
|||||||
name: backendia
|
|
||||||
|
|
||||||
services:
|
|
||||||
backendia:
|
|
||||||
build:
|
|
||||||
context: ./backendia
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: backendia-prod
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3005:3000"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
- PORT=3000
|
|
||||||
volumes:
|
|
||||||
- backendia_uploads:/app/uploads
|
|
||||||
- backendia_exports:/app/exports
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
backendia_uploads:
|
|
||||||
backendia_exports:
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'core/theme/theme_provider.dart';
|
import 'core/theme/theme_provider.dart';
|
||||||
|
import 'core/theme/app_theme.dart';
|
||||||
import 'main_navigation_holder.dart';
|
import 'main_navigation_holder.dart';
|
||||||
|
import 'features/home/home_screen.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
class BullyApp extends StatelessWidget {
|
class BullyApp extends StatelessWidget {
|
||||||
@@ -14,8 +16,8 @@ class BullyApp extends StatelessWidget {
|
|||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Bully - Analyse de Cibles',
|
title: 'Bully - Analyse de Cibles',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: themeProvider.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: themeProvider.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: themeProvider.themeMode,
|
themeMode: themeProvider.themeMode,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
@@ -26,7 +28,7 @@ class BullyApp extends StatelessWidget {
|
|||||||
Locale('fr', 'FR'), // Français
|
Locale('fr', 'FR'), // Français
|
||||||
],
|
],
|
||||||
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
||||||
home: MainNavigationHolder(key: mainNavKey),
|
home: const MainNavigationHolder(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,424 +1,89 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
// Accents par défaut (compatibilité statique)
|
static const Color primaryColor = Color(0xFF1E88E5);
|
||||||
static const Color primaryColor = Color(0xFF2563EB);
|
static const Color secondaryColor = Color(0xFF43A047);
|
||||||
static const Color primaryLight = Color(0xFF60A5FA);
|
static const Color errorColor = Color(0xFFE53935);
|
||||||
static const Color primaryDark = Color(0xFF1D4ED8);
|
static const Color warningColor = Color(0xFFFFA726);
|
||||||
|
static const Color successColor = Color(0xFF66BB6A);
|
||||||
|
|
||||||
static const Color secondaryColor = Color(0xFF10B981);
|
static const Color backgroundColor = Color(0xFFF5F5F5);
|
||||||
static const Color secondaryDark = Color(0xFF059669);
|
static const Color surfaceColor = Colors.white;
|
||||||
|
static const Color textPrimary = Color(0xFF212121);
|
||||||
|
static const Color textSecondary = Color(0xFF757575);
|
||||||
|
|
||||||
static const Color accentBlue = Color(0xFF0EA5E9);
|
// Impact colors for visualization
|
||||||
static const Color accentGold = Color(0xFFFFB300);
|
static const Color impactColor = Color(0xFFFF5722);
|
||||||
|
|
||||||
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 impactOutlineColor = Color(0xFFFFFFFF);
|
||||||
static const Color groupingCenterColor = Color(0xFF00E5FF);
|
static const Color groupingCenterColor = Color(0xFF2196F3);
|
||||||
static const Color groupingCircleColor = Color(0x4D00E5FF);
|
static const Color groupingCircleColor = Color(0x4D2196F3);
|
||||||
|
|
||||||
// Couleurs des zones de score cibles concentriques
|
// Score zone colors
|
||||||
static const List<Color> zoneColors = [
|
static const List<Color> zoneColors = [
|
||||||
Color(0xFFFFB300), // Zone 10 - Or
|
Color(0xFFFFEB3B), // Zone 10 - Gold
|
||||||
Color(0xFFFFCA28), // Zone 9
|
Color(0xFFFFEB3B), // Zone 9
|
||||||
Color(0xFFFF5722), // Zone 8
|
Color(0xFFFF5722), // Zone 8
|
||||||
Color(0xFFFF7043), // Zone 7
|
Color(0xFFFF5722), // Zone 7
|
||||||
Color(0xFF29B6F6), // Zone 6
|
Color(0xFF2196F3), // Zone 6
|
||||||
Color(0xFF4FC3F7), // Zone 5
|
Color(0xFF2196F3), // Zone 5
|
||||||
Color(0xFF66BB6A), // Zone 4
|
Color(0xFF4CAF50), // Zone 4
|
||||||
Color(0xFF81C784), // Zone 3
|
Color(0xFF4CAF50), // Zone 3
|
||||||
Color(0xFFFFFFFF), // Zone 2
|
Color(0xFFFFFFFF), // Zone 2
|
||||||
Color(0xFFE0E0E0), // Zone 1
|
Color(0xFFFFFFFF), // Zone 1
|
||||||
];
|
];
|
||||||
|
|
||||||
static ThemeData get lightTheme => buildLightTheme(AppAccentColor.blue);
|
static ThemeData get lightTheme {
|
||||||
static ThemeData get darkTheme => buildDarkTheme(AppAccentColor.blue);
|
|
||||||
|
|
||||||
static ThemeData buildLightTheme(AppAccentColor accent) {
|
|
||||||
final activePrimary = accent.color;
|
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.light,
|
colorScheme: ColorScheme.fromSeed(
|
||||||
colorScheme: ColorScheme.light(
|
seedColor: primaryColor,
|
||||||
primary: activePrimary,
|
brightness: Brightness.light,
|
||||||
secondary: secondaryColor,
|
|
||||||
surface: lightSurface,
|
|
||||||
error: errorColor,
|
|
||||||
onPrimary: Colors.white,
|
|
||||||
onSecondary: Colors.white,
|
|
||||||
onSurface: lightTextPrimary,
|
|
||||||
onError: Colors.white,
|
|
||||||
outline: lightBorder,
|
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: lightBackground,
|
scaffoldBackgroundColor: backgroundColor,
|
||||||
cardColor: lightSurface,
|
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
backgroundColor: lightSurface,
|
backgroundColor: primaryColor,
|
||||||
foregroundColor: lightTextPrimary,
|
foregroundColor: Colors.white,
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
titleTextStyle: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: lightTextPrimary,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 0,
|
elevation: 2,
|
||||||
color: lightSurface,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
side: const BorderSide(color: lightBorder, width: 1),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
elevation: 0,
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
backgroundColor: activePrimary,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
minimumSize: const Size(double.infinity, 50),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||||
style: OutlinedButton.styleFrom(
|
backgroundColor: primaryColor,
|
||||||
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,
|
foregroundColor: Colors.white,
|
||||||
elevation: 3,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static ThemeData buildDarkTheme(AppAccentColor accent) {
|
static ThemeData get darkTheme {
|
||||||
final activePrimary = accent.color;
|
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
colorScheme: ColorScheme.fromSeed(
|
||||||
colorScheme: ColorScheme.dark(
|
seedColor: primaryColor,
|
||||||
primary: activePrimary,
|
brightness: Brightness.dark,
|
||||||
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(
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
backgroundColor: darkBackground,
|
|
||||||
foregroundColor: darkTextPrimary,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
titleTextStyle: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: darkTextPrimary,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 0,
|
elevation: 2,
|
||||||
color: darkSurface,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
shape: RoundedRectangleBorder(
|
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),
|
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)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,59 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'app_theme.dart';
|
|
||||||
|
|
||||||
class ThemeProvider with ChangeNotifier {
|
class ThemeProvider with ChangeNotifier {
|
||||||
static const String _themeModeKey = 'user_theme_mode';
|
static const String _themeModeKey = 'user_theme_mode';
|
||||||
static const String _accentKey = 'user_accent_color';
|
|
||||||
|
|
||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
AppAccentColor _accent = AppAccentColor.blue;
|
|
||||||
|
|
||||||
ThemeMode get themeMode => _themeMode;
|
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() {
|
ThemeProvider() {
|
||||||
loadSettings();
|
loadThemeMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadSettings() async {
|
Future<void> loadThemeMode() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final modeIndex = prefs.getInt(_themeModeKey);
|
final modeIndex = prefs.getInt(_themeModeKey);
|
||||||
final accentId = prefs.getString(_accentKey);
|
|
||||||
|
|
||||||
if (modeIndex != null) {
|
if (modeIndex != null) {
|
||||||
_themeMode = ThemeMode.values[modeIndex];
|
_themeMode = ThemeMode.values[modeIndex];
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
if (accentId != null) {
|
|
||||||
_accent = AppAccentColor.fromId(accentId);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setThemeMode(ThemeMode mode) async {
|
Future<void> setThemeMode(ThemeMode mode) async {
|
||||||
if (_themeMode == mode) return;
|
if (_themeMode == mode) return;
|
||||||
|
|
||||||
_themeMode = mode;
|
_themeMode = mode;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_themeModeKey, mode.index);
|
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 {
|
String get themeModeName {
|
||||||
switch (_themeMode) {
|
switch (_themeMode) {
|
||||||
case ThemeMode.system:
|
case ThemeMode.system:
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/// 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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
@@ -10,12 +11,6 @@ import '../../core/constants/app_constants.dart';
|
|||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
static DatabaseHelper? _instance;
|
static DatabaseHelper? _instance;
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
// On met en cache le Future d'initialisation (et non la Database résolue)
|
|
||||||
// pour éviter qu'un démarrage concurrent (les 4 onglets de l'IndexedStack
|
|
||||||
// interrogent la base en même temps) ne lance plusieurs _initDatabase() en
|
|
||||||
// parallèle. Sur une base fraîche, cela dédoublait onCreate et rendait la
|
|
||||||
// toute première écriture peu fiable.
|
|
||||||
static Future<Database>? _initFuture;
|
|
||||||
|
|
||||||
DatabaseHelper._internal();
|
DatabaseHelper._internal();
|
||||||
|
|
||||||
@@ -25,9 +20,7 @@ class DatabaseHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
if (_database != null) return _database!;
|
_database ??= await _initDatabase();
|
||||||
_initFuture ??= _initDatabase();
|
|
||||||
_database = await _initFuture!;
|
|
||||||
return _database!;
|
return _database!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,71 +373,17 @@ class DatabaseHelper {
|
|||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
);
|
);
|
||||||
if (sessionMaps.isEmpty) return [];
|
|
||||||
|
|
||||||
// 3 requêtes groupées au lieu d'une cascade par session puis par analyse
|
final sessions = <Session>[];
|
||||||
// (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
|
for (final sessionMap in sessionMaps) {
|
||||||
// ce qui ralentissait l'accueil/historique/stats au fil des mois.
|
final sessionId = sessionMap['id'] as String;
|
||||||
final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
|
final session = await getSession(sessionId);
|
||||||
final analysisMaps = await _queryIn(
|
if (session != null) {
|
||||||
db,
|
sessions.add(session);
|
||||||
AppConstants.targetAnalysesTable,
|
}
|
||||||
'session_id',
|
|
||||||
sessionIds,
|
|
||||||
);
|
|
||||||
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
|
|
||||||
final shotMaps = await _queryIn(
|
|
||||||
db,
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
'analysis_id',
|
|
||||||
analysisIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
final shotsByAnalysis = <String, List<Shot>>{};
|
|
||||||
for (final map in shotMaps) {
|
|
||||||
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
|
|
||||||
.add(Shot.fromMap(map));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final analysesBySession = <String, List<TargetAnalysis>>{};
|
return sessions;
|
||||||
for (final map in analysisMaps) {
|
|
||||||
final analysis = TargetAnalysis.fromMap(
|
|
||||||
map,
|
|
||||||
shotsByAnalysis[map['id'] as String] ?? [],
|
|
||||||
);
|
|
||||||
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionMaps
|
|
||||||
.map((m) =>
|
|
||||||
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
|
|
||||||
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
|
|
||||||
Future<List<Map<String, Object?>>> _queryIn(
|
|
||||||
Database db,
|
|
||||||
String table,
|
|
||||||
String column,
|
|
||||||
List<String> values,
|
|
||||||
) async {
|
|
||||||
if (values.isEmpty) return [];
|
|
||||||
const chunkSize = 500;
|
|
||||||
final results = <Map<String, Object?>>[];
|
|
||||||
for (var i = 0; i < values.length; i += chunkSize) {
|
|
||||||
final chunk = values.sublist(
|
|
||||||
i,
|
|
||||||
i + chunkSize > values.length ? values.length : i + chunkSize,
|
|
||||||
);
|
|
||||||
final placeholders = List.filled(chunk.length, '?').join(',');
|
|
||||||
results.addAll(await db.query(
|
|
||||||
table,
|
|
||||||
where: '$column IN ($placeholders)',
|
|
||||||
whereArgs: chunk,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> deleteSession(String id) async {
|
Future<int> deleteSession(String id) async {
|
||||||
@@ -571,16 +510,6 @@ class DatabaseHelper {
|
|||||||
return Sqflite.firstIntValue(result) ?? 0;
|
return Sqflite.firstIntValue(result) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> getSessionCountForWeapon(String weaponId) async {
|
|
||||||
final db = await database;
|
|
||||||
final result = await db.rawQuery('''
|
|
||||||
SELECT COUNT(id) as count
|
|
||||||
FROM ${AppConstants.sessionsTable}
|
|
||||||
WHERE weapon_id = ?
|
|
||||||
''', [weaponId]);
|
|
||||||
return Sqflite.firstIntValue(result) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> insertMaintenance(MaintenanceEntry entry) async {
|
Future<int> insertMaintenance(MaintenanceEntry entry) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return await db.insert(
|
return await db.insert(
|
||||||
@@ -590,16 +519,6 @@ class DatabaseHelper {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toutes les entrées de maintenance, armes confondues (export/sauvegarde).
|
|
||||||
Future<List<MaintenanceEntry>> getAllMaintenance() async {
|
|
||||||
final db = await database;
|
|
||||||
final maps = await db.query(
|
|
||||||
AppConstants.maintenanceTable,
|
|
||||||
orderBy: 'date DESC',
|
|
||||||
);
|
|
||||||
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
|
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final maps = await db.query(
|
final maps = await db.query(
|
||||||
@@ -624,6 +543,5 @@ class DatabaseHelper {
|
|||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
_database = null;
|
_database = null;
|
||||||
_initFuture = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
enum WeaponType {
|
enum WeaponType {
|
||||||
handgun('Arme de Poing', Icons.shield),
|
handgun('Arme de Poing'),
|
||||||
rifle('Arme d\'Épaule', Icons.filter_center_focus),
|
rifle('Arme d\'Épaule'),
|
||||||
shotgun('Fusil à Pompe', Icons.splitscreen),
|
shotgun('Fusil à Pompe'),
|
||||||
airgun('Airsoft / Airgun', Icons.air);
|
airgun('Airsoft / Airgun');
|
||||||
|
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final IconData defaultIcon;
|
const WeaponType(this.displayName);
|
||||||
const WeaponType(this.displayName, this.defaultIcon);
|
|
||||||
|
|
||||||
static WeaponType fromString(String value) {
|
static WeaponType fromString(String value) {
|
||||||
return WeaponType.values.firstWhere(
|
return WeaponType.values.firstWhere(
|
||||||
|
|||||||
@@ -131,29 +131,6 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getStatistics();
|
return await _databaseHelper.getStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enregistre une session déjà construite (import de sauvegarde).
|
|
||||||
/// Les identifiants existants sont écrasés : réimporter deux fois la même
|
|
||||||
/// sauvegarde ne crée pas de doublons.
|
|
||||||
Future<void> saveSession(Session session) async {
|
|
||||||
await _databaseHelper.insertSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copie une image dans le dossier des cibles de l'app (import de sauvegarde).
|
|
||||||
Future<String> saveImageBytes(List<int> bytes, String extension) async {
|
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
|
||||||
final imagesDir = Directory(path.join(appDir.path, 'target_images'));
|
|
||||||
|
|
||||||
if (!await imagesDir.exists()) {
|
|
||||||
await imagesDir.create(recursive: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
final fileName = '${_uuid.v4()}$extension';
|
|
||||||
final destPath = path.join(imagesDir.path, fileName);
|
|
||||||
await File(destPath).writeAsBytes(bytes);
|
|
||||||
|
|
||||||
return destPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
String generateId() {
|
String generateId() {
|
||||||
return _uuid.v4();
|
return _uuid.v4();
|
||||||
}
|
}
|
||||||
@@ -193,11 +170,6 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getAllWeapons();
|
return await _databaseHelper.getAllWeapons();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enregistre une arme déjà construite (import de sauvegarde).
|
|
||||||
Future<void> saveWeapon(Weapon weapon) async {
|
|
||||||
await _databaseHelper.insertWeapon(weapon);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateWeapon(Weapon weapon) async {
|
Future<void> updateWeapon(Weapon weapon) async {
|
||||||
await _databaseHelper.updateWeapon(weapon);
|
await _databaseHelper.updateWeapon(weapon);
|
||||||
}
|
}
|
||||||
@@ -210,23 +182,18 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> getSessionCountForWeapon(String weaponId) async {
|
|
||||||
return await _databaseHelper.getSessionCountForWeapon(weaponId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addMaintenanceEntry({
|
Future<void> addMaintenanceEntry({
|
||||||
required String weaponId,
|
required String weaponId,
|
||||||
required MaintenanceType type,
|
required MaintenanceType type,
|
||||||
required String description,
|
required String description,
|
||||||
int? roundsSinceLast,
|
int? roundsSinceLast,
|
||||||
DateTime? date,
|
|
||||||
}) async {
|
}) async {
|
||||||
final entry = MaintenanceEntry(
|
final entry = MaintenanceEntry(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
weaponId: weaponId,
|
weaponId: weaponId,
|
||||||
type: type,
|
type: type,
|
||||||
description: description,
|
description: description,
|
||||||
date: date ?? DateTime.now(),
|
date: DateTime.now(),
|
||||||
roundsSinceLastMaintenance: roundsSinceLast,
|
roundsSinceLastMaintenance: roundsSinceLast,
|
||||||
);
|
);
|
||||||
await _databaseHelper.insertMaintenance(entry);
|
await _databaseHelper.insertMaintenance(entry);
|
||||||
@@ -236,16 +203,6 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
|
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tout l'historique de maintenance de l'armurerie (export de sauvegarde).
|
|
||||||
Future<List<MaintenanceEntry>> getAllMaintenance() async {
|
|
||||||
return await _databaseHelper.getAllMaintenance();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enregistre une entrée de maintenance déjà construite (import).
|
|
||||||
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
|
|
||||||
await _databaseHelper.insertMaintenance(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteMaintenanceEntry(String id) async {
|
Future<void> deleteMaintenanceEntry(String id) async {
|
||||||
await _databaseHelper.deleteMaintenance(id);
|
await _databaseHelper.deleteMaintenance(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
|
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
|
||||||
///
|
///
|
||||||
/// Gère le workflow complet d'analyse : chargement d'image, gestion des
|
/// Gère le workflow complet d'analyse : chargement d'image, détection de cible,
|
||||||
/// impacts placés manuellement, calcul des scores, analyse de groupement
|
/// gestion des impacts (manuels et automatiques), calcul des scores,
|
||||||
/// et sauvegarde des sessions.
|
/// analyse de groupement et sauvegarde des sessions.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -13,25 +13,37 @@ import '../../data/models/target_analysis.dart';
|
|||||||
import '../../data/models/shot.dart';
|
import '../../data/models/shot.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
|
import '../../services/target_detection_service.dart';
|
||||||
import '../../services/score_calculator_service.dart';
|
import '../../services/score_calculator_service.dart';
|
||||||
import '../../services/grouping_analyzer_service.dart';
|
import '../../services/grouping_analyzer_service.dart';
|
||||||
|
import '../../services/distortion_correction_service.dart';
|
||||||
|
import '../../services/opencv_target_service.dart';
|
||||||
import '../../services/ai_export_service.dart';
|
import '../../services/ai_export_service.dart';
|
||||||
|
|
||||||
enum AnalysisState { initial, loading, success, error }
|
enum AnalysisState { initial, loading, success, error }
|
||||||
|
|
||||||
class AnalysisProvider extends ChangeNotifier {
|
class AnalysisProvider extends ChangeNotifier {
|
||||||
|
final TargetDetectionService _detectionService;
|
||||||
final ScoreCalculatorService _scoreCalculatorService;
|
final ScoreCalculatorService _scoreCalculatorService;
|
||||||
final GroupingAnalyzerService _groupingAnalyzerService;
|
final GroupingAnalyzerService _groupingAnalyzerService;
|
||||||
final SessionRepository _sessionRepository;
|
final SessionRepository _sessionRepository;
|
||||||
|
final DistortionCorrectionService _distortionService;
|
||||||
|
final OpenCVTargetService _opencvTargetService;
|
||||||
final Uuid _uuid = const Uuid();
|
final Uuid _uuid = const Uuid();
|
||||||
|
|
||||||
AnalysisProvider({
|
AnalysisProvider({
|
||||||
|
required TargetDetectionService detectionService,
|
||||||
required ScoreCalculatorService scoreCalculatorService,
|
required ScoreCalculatorService scoreCalculatorService,
|
||||||
required GroupingAnalyzerService groupingAnalyzerService,
|
required GroupingAnalyzerService groupingAnalyzerService,
|
||||||
required SessionRepository sessionRepository,
|
required SessionRepository sessionRepository,
|
||||||
}) : _scoreCalculatorService = scoreCalculatorService,
|
DistortionCorrectionService? distortionService,
|
||||||
|
OpenCVTargetService? opencvTargetService,
|
||||||
|
}) : _detectionService = detectionService,
|
||||||
|
_scoreCalculatorService = scoreCalculatorService,
|
||||||
_groupingAnalyzerService = groupingAnalyzerService,
|
_groupingAnalyzerService = groupingAnalyzerService,
|
||||||
_sessionRepository = sessionRepository;
|
_sessionRepository = sessionRepository,
|
||||||
|
_distortionService = distortionService ?? DistortionCorrectionService(),
|
||||||
|
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
|
||||||
|
|
||||||
AnalysisState _state = AnalysisState.initial;
|
AnalysisState _state = AnalysisState.initial;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
@@ -41,7 +53,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
|
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
|
||||||
double _cropRotation = 0.0;
|
double _cropRotation = 0.0;
|
||||||
|
|
||||||
// Target calibration
|
// Target detection results
|
||||||
double _targetCenterX = 0.5;
|
double _targetCenterX = 0.5;
|
||||||
double _targetCenterY = 0.5;
|
double _targetCenterY = 0.5;
|
||||||
double _targetRadius = 0.4;
|
double _targetRadius = 0.4;
|
||||||
@@ -59,6 +71,15 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
// Grouping results
|
// Grouping results
|
||||||
GroupingResult? _groupingResult;
|
GroupingResult? _groupingResult;
|
||||||
|
|
||||||
|
// Reference-based detection
|
||||||
|
List<Shot> _referenceImpacts = [];
|
||||||
|
ImpactCharacteristics? _learnedCharacteristics;
|
||||||
|
|
||||||
|
// Distortion correction
|
||||||
|
bool _distortionCorrectionEnabled = false;
|
||||||
|
DistortionParameters? _distortionParams;
|
||||||
|
String? _correctedImagePath;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
AnalysisState get state => _state;
|
AnalysisState get state => _state;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
@@ -79,6 +100,21 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
int get totalScore => _scoreResult?.totalScore ?? 0;
|
int get totalScore => _scoreResult?.totalScore ?? 0;
|
||||||
int get shotCount => _shots.length;
|
int get shotCount => _shots.length;
|
||||||
|
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
|
||||||
|
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
|
||||||
|
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
|
||||||
|
|
||||||
|
// Distortion correction getters
|
||||||
|
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
|
||||||
|
DistortionParameters? get distortionParams => _distortionParams;
|
||||||
|
String? get correctedImagePath => _correctedImagePath;
|
||||||
|
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
|
||||||
|
|
||||||
|
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
|
||||||
|
String? get displayImagePath =>
|
||||||
|
_distortionCorrectionEnabled && _correctedImagePath != null
|
||||||
|
? _correctedImagePath
|
||||||
|
: _imagePath;
|
||||||
|
|
||||||
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
||||||
void setCropRotation(double rotation) {
|
void setCropRotation(double rotation) {
|
||||||
@@ -86,13 +122,16 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Charge l'image et initialise les paramètres de cible par défaut.
|
/// Analyze an image
|
||||||
/// Le placement des impacts et la calibration se font ensuite manuellement.
|
///
|
||||||
|
/// [autoAnalyze] determines if we should run automatic detection immediately.
|
||||||
|
/// If false, only the image is loaded and default target parameters are set.
|
||||||
Future<void> analyzeImage(
|
Future<void> analyzeImage(
|
||||||
String imagePath,
|
String imagePath,
|
||||||
TargetType targetType, {
|
TargetType targetType, {
|
||||||
Offset? manualCenter,
|
bool autoAnalyze = true,
|
||||||
}) async {
|
Offset? manualCenter,
|
||||||
|
}) async {
|
||||||
_state = AnalysisState.loading;
|
_state = AnalysisState.loading;
|
||||||
_imagePath = imagePath;
|
_imagePath = imagePath;
|
||||||
_targetType = targetType;
|
_targetType = targetType;
|
||||||
@@ -108,12 +147,54 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_imageAspectRatio = frame.image.width / frame.image.height;
|
_imageAspectRatio = frame.image.width / frame.image.height;
|
||||||
frame.image.dispose();
|
frame.image.dispose();
|
||||||
|
|
||||||
_targetCenterX = manualCenter?.dx ?? 0.5;
|
if (!autoAnalyze) {
|
||||||
_targetCenterY = manualCenter?.dy ?? 0.5;
|
// Just setup default values without running detection
|
||||||
_targetRadius = 0.4;
|
_targetCenterX = manualCenter?.dx ?? 0.5;
|
||||||
_targetInnerRadius = 0.04;
|
_targetCenterY = manualCenter?.dy ?? 0.5;
|
||||||
|
_targetRadius = 0.4;
|
||||||
|
_targetInnerRadius = 0.04;
|
||||||
|
|
||||||
_shots = [];
|
// Initialize empty shots list
|
||||||
|
_shots = [];
|
||||||
|
|
||||||
|
_state = AnalysisState.success;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _detectionService.detectTargetAsync(
|
||||||
|
imagePath,
|
||||||
|
targetType,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
_state = AnalysisState.error;
|
||||||
|
_errorMessage = result.errorMessage;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_targetCenterX = result.centerX;
|
||||||
|
_targetCenterY = result.centerY;
|
||||||
|
_targetRadius = result.radius;
|
||||||
|
_targetInnerRadius = result.radius * 0.1;
|
||||||
|
|
||||||
|
// Create shots from detected impacts
|
||||||
|
_shots = result.impacts.map((impact) {
|
||||||
|
return Shot(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
x: impact.x,
|
||||||
|
y: impact.y,
|
||||||
|
score: impact.suggestedScore,
|
||||||
|
analysisId: '',
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Calculate scores
|
||||||
|
_recalculateScores();
|
||||||
|
|
||||||
|
// Calculate grouping
|
||||||
|
_recalculateGrouping();
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
_state = AnalysisState.success;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -135,18 +216,22 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Synthèse).
|
/// Remove a shot
|
||||||
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
|
void removeShot(String shotId) {
|
||||||
void clearShots() {
|
_shots.removeWhere((shot) => shot.id == shotId);
|
||||||
_shots.clear();
|
|
||||||
_recalculateScores();
|
_recalculateScores();
|
||||||
_recalculateGrouping();
|
_recalculateGrouping();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a shot
|
/// Move a shot to a new position
|
||||||
void removeShot(String shotId) {
|
void moveShot(String shotId, double newX, double newY) {
|
||||||
_shots.removeWhere((shot) => shot.id == shotId);
|
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();
|
_recalculateScores();
|
||||||
_recalculateGrouping();
|
_recalculateGrouping();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -162,17 +247,276 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-detect impacts using image processing
|
||||||
|
Future<int> autoDetectImpacts({
|
||||||
|
int darkThreshold = 80,
|
||||||
|
int minImpactSize = 20,
|
||||||
|
int maxImpactSize = 500,
|
||||||
|
double minCircularity = 0.6,
|
||||||
|
double minFillRatio = 0.5,
|
||||||
|
bool clearExisting = false,
|
||||||
|
}) async {
|
||||||
|
if (_imagePath == null || _targetType == null) return 0;
|
||||||
|
|
||||||
|
final settings = ImpactDetectionSettings(
|
||||||
|
darkThreshold: darkThreshold,
|
||||||
|
minImpactSize: minImpactSize,
|
||||||
|
maxImpactSize: maxImpactSize,
|
||||||
|
minCircularity: minCircularity,
|
||||||
|
minFillRatio: minFillRatio,
|
||||||
|
);
|
||||||
|
|
||||||
|
final detectedImpacts = _detectionService.detectImpactsOnly(
|
||||||
|
_imagePath!,
|
||||||
|
_targetType!,
|
||||||
|
_targetCenterX,
|
||||||
|
_targetCenterY,
|
||||||
|
_targetRadius,
|
||||||
|
_ringCount,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clearExisting) {
|
||||||
|
_shots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add detected impacts as shots
|
||||||
|
for (final impact in detectedImpacts) {
|
||||||
|
final score = _calculateShotScore(impact.x, impact.y);
|
||||||
|
final shot = Shot(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
x: impact.x,
|
||||||
|
y: impact.y,
|
||||||
|
score: score,
|
||||||
|
analysisId: '',
|
||||||
|
);
|
||||||
|
_shots.add(shot);
|
||||||
|
}
|
||||||
|
|
||||||
|
_recalculateScores();
|
||||||
|
_recalculateGrouping();
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
return detectedImpacts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
|
||||||
|
Future<int> autoDetectImpactsWithOpenCV({
|
||||||
|
double cannyThreshold1 = 50,
|
||||||
|
double cannyThreshold2 = 150,
|
||||||
|
double minDist = 20,
|
||||||
|
double param1 = 100,
|
||||||
|
double param2 = 30,
|
||||||
|
int minRadius = 5,
|
||||||
|
int maxRadius = 50,
|
||||||
|
int minSize = 5,
|
||||||
|
int maxSize = 1000,
|
||||||
|
int blurSize = 5,
|
||||||
|
bool useContourDetection = true,
|
||||||
|
double minCircularity = 0.6,
|
||||||
|
double minContourArea = 50,
|
||||||
|
double maxContourArea = 5000,
|
||||||
|
bool clearExisting = false,
|
||||||
|
}) async {
|
||||||
|
if (_imagePath == null || _targetType == null) return 0;
|
||||||
|
|
||||||
|
final settings = OpenCVDetectionSettings(
|
||||||
|
cannyThreshold1: cannyThreshold1,
|
||||||
|
cannyThreshold2: cannyThreshold2,
|
||||||
|
minDist: minDist,
|
||||||
|
param1: param1,
|
||||||
|
param2: param2,
|
||||||
|
minRadius: minRadius,
|
||||||
|
maxRadius: maxRadius,
|
||||||
|
blurSize: blurSize,
|
||||||
|
useContourDetection: useContourDetection,
|
||||||
|
minCircularity: minCircularity,
|
||||||
|
minContourArea: minContourArea,
|
||||||
|
maxContourArea: maxContourArea,
|
||||||
|
);
|
||||||
|
|
||||||
|
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
|
||||||
|
_imagePath!,
|
||||||
|
_targetType!,
|
||||||
|
_targetCenterX,
|
||||||
|
_targetCenterY,
|
||||||
|
_targetRadius,
|
||||||
|
_ringCount,
|
||||||
|
settings: settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clearExisting) {
|
||||||
|
_shots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add detected impacts as shots
|
||||||
|
for (final impact in detectedImpacts) {
|
||||||
|
final score = _calculateShotScore(impact.x, impact.y);
|
||||||
|
final shot = Shot(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
x: impact.x,
|
||||||
|
y: impact.y,
|
||||||
|
score: score,
|
||||||
|
analysisId: '',
|
||||||
|
);
|
||||||
|
_shots.add(shot);
|
||||||
|
}
|
||||||
|
|
||||||
|
_recalculateScores();
|
||||||
|
_recalculateGrouping();
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
return detectedImpacts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect impacts with OpenCV using reference points
|
||||||
|
Future<int> detectFromReferencesWithOpenCV({
|
||||||
|
double tolerance = 2.0,
|
||||||
|
bool clearExisting = false,
|
||||||
|
}) async {
|
||||||
|
if (_imagePath == null ||
|
||||||
|
_targetType == null ||
|
||||||
|
_referenceImpacts.length < 2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir les références
|
||||||
|
final references = _referenceImpacts
|
||||||
|
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final detectedImpacts = _detectionService
|
||||||
|
.detectImpactsWithOpenCVFromReferences(
|
||||||
|
_imagePath!,
|
||||||
|
_targetType!,
|
||||||
|
_targetCenterX,
|
||||||
|
_targetCenterY,
|
||||||
|
_targetRadius,
|
||||||
|
_ringCount,
|
||||||
|
references,
|
||||||
|
tolerance: tolerance,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clearExisting) {
|
||||||
|
_shots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add detected impacts as shots
|
||||||
|
for (final impact in detectedImpacts) {
|
||||||
|
final score = _calculateShotScore(impact.x, impact.y);
|
||||||
|
final shot = Shot(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
x: impact.x,
|
||||||
|
y: impact.y,
|
||||||
|
score: score,
|
||||||
|
analysisId: '',
|
||||||
|
);
|
||||||
|
_shots.add(shot);
|
||||||
|
}
|
||||||
|
|
||||||
|
_recalculateScores();
|
||||||
|
_recalculateGrouping();
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
return detectedImpacts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a reference impact for calibrated detection
|
||||||
|
void addReferenceImpact(double x, double y) {
|
||||||
|
final score = _calculateShotScore(x, y);
|
||||||
|
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
|
||||||
|
_referenceImpacts.add(shot);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a reference impact
|
||||||
|
void removeReferenceImpact(String shotId) {
|
||||||
|
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
|
||||||
|
_learnedCharacteristics = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all reference impacts
|
||||||
|
void clearReferenceImpacts() {
|
||||||
|
_referenceImpacts.clear();
|
||||||
|
_learnedCharacteristics = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Learn characteristics from reference impacts
|
||||||
|
bool learnFromReferences() {
|
||||||
|
if (_imagePath == null || _referenceImpacts.length < 2) return false;
|
||||||
|
|
||||||
|
final references = _referenceImpacts
|
||||||
|
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
|
||||||
|
_imagePath!,
|
||||||
|
references,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
return _learnedCharacteristics != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-detect impacts using learned reference characteristics
|
||||||
|
Future<int> detectFromReferences({
|
||||||
|
double tolerance = 2.0,
|
||||||
|
bool clearExisting = false,
|
||||||
|
}) async {
|
||||||
|
if (_imagePath == null ||
|
||||||
|
_targetType == null ||
|
||||||
|
_learnedCharacteristics == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final detectedImpacts = _detectionService.detectImpactsFromReferences(
|
||||||
|
_imagePath!,
|
||||||
|
_targetType!,
|
||||||
|
_targetCenterX,
|
||||||
|
_targetCenterY,
|
||||||
|
_targetRadius,
|
||||||
|
_ringCount,
|
||||||
|
_learnedCharacteristics!,
|
||||||
|
tolerance: tolerance,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clearExisting) {
|
||||||
|
_shots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add detected impacts as shots
|
||||||
|
for (final impact in detectedImpacts) {
|
||||||
|
final score = _calculateShotScore(impact.x, impact.y);
|
||||||
|
final shot = Shot(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
x: impact.x,
|
||||||
|
y: impact.y,
|
||||||
|
score: score,
|
||||||
|
analysisId: '',
|
||||||
|
);
|
||||||
|
_shots.add(shot);
|
||||||
|
}
|
||||||
|
|
||||||
|
_recalculateScores();
|
||||||
|
_recalculateGrouping();
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
return detectedImpacts.length;
|
||||||
|
}
|
||||||
|
|
||||||
/// Adjust target position
|
/// Adjust target position
|
||||||
void adjustTargetPosition(
|
void adjustTargetPosition(
|
||||||
double centerX,
|
double centerX,
|
||||||
double centerY,
|
double centerY,
|
||||||
double innerRadius,
|
double innerRadius,
|
||||||
double radius, {
|
double radius, {
|
||||||
int? ringCount,
|
int? ringCount,
|
||||||
List<double>? ringRadii,
|
List<double>? ringRadii,
|
||||||
double zoomScale = 1.0,
|
double zoomScale = 1.0,
|
||||||
Offset offset = Offset.zero,
|
Offset offset = Offset.zero,
|
||||||
}) {
|
}) {
|
||||||
_targetCenterX = (centerX - offset.dx) / zoomScale;
|
_targetCenterX = (centerX - offset.dx) / zoomScale;
|
||||||
_targetCenterY = (centerY - offset.dy) / zoomScale;
|
_targetCenterY = (centerY - offset.dy) / zoomScale;
|
||||||
_targetRadius = radius / zoomScale;
|
_targetRadius = radius / zoomScale;
|
||||||
@@ -195,6 +539,118 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-calibrate target using OpenCV
|
||||||
|
Future<bool> autoCalibrateTarget() async {
|
||||||
|
if (_imagePath == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Attempt to correct perspective/distortion first
|
||||||
|
final correctedPath = await _distortionService
|
||||||
|
.correctPerspectiveWithConcentricMesh(_imagePath!);
|
||||||
|
|
||||||
|
if (correctedPath != _imagePath) {
|
||||||
|
_imagePath = correctedPath;
|
||||||
|
_correctedImagePath = correctedPath;
|
||||||
|
_distortionCorrectionEnabled = true;
|
||||||
|
_imageAspectRatio = 1.0;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Detect the target on the straight/corrected image
|
||||||
|
final result = await _opencvTargetService.detectTarget(_imagePath!);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
adjustTargetPosition(
|
||||||
|
result.centerX,
|
||||||
|
result.centerY,
|
||||||
|
result.radius * 0.1,
|
||||||
|
result.radius,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Auto-calibration error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
|
||||||
|
void calculateDistortion() {
|
||||||
|
_distortionParams = _distortionService.calculateDistortionFromCalibration(
|
||||||
|
targetCenterX: _targetCenterX,
|
||||||
|
targetCenterY: _targetCenterY,
|
||||||
|
targetRadius: _targetRadius,
|
||||||
|
imageAspectRatio: _imageAspectRatio,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applique la correction de distorsion à l'image
|
||||||
|
/// Crée une nouvelle image corrigée et la sauvegarde
|
||||||
|
Future<void> applyDistortionCorrection() async {
|
||||||
|
if (_imagePath == null || _distortionParams == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_correctedImagePath = await _distortionService.applyCorrection(
|
||||||
|
_imagePath!,
|
||||||
|
_distortionParams!,
|
||||||
|
);
|
||||||
|
_distortionCorrectionEnabled = true;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
_errorMessage = 'Erreur lors de la correction: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active ou désactive l'affichage de l'image corrigée
|
||||||
|
void setDistortionCorrectionEnabled(bool enabled) {
|
||||||
|
if (enabled && _correctedImagePath == null && _distortionParams != null) {
|
||||||
|
// Si on active mais pas encore d'image corrigée, la créer
|
||||||
|
applyDistortionCorrection();
|
||||||
|
} else {
|
||||||
|
_distortionCorrectionEnabled = enabled;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule ET applique la correction pour un feedback immédiat
|
||||||
|
Future<void> calculateAndApplyDistortion() async {
|
||||||
|
// 1. Calcul des paramètres (votre code actuel)
|
||||||
|
_distortionParams = _distortionService.calculateDistortionFromCalibration(
|
||||||
|
targetCenterX: _targetCenterX,
|
||||||
|
targetCenterY: _targetCenterY,
|
||||||
|
targetRadius: _targetRadius,
|
||||||
|
imageAspectRatio: _imageAspectRatio,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Vérification si une correction est réellement nécessaire
|
||||||
|
if (_distortionParams != null && _distortionParams!.needsCorrection) {
|
||||||
|
// 3. Application immédiate de la transformation (méthode asynchrone)
|
||||||
|
await applyDistortionCorrection();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> runFullDistortionWorkflow() async {
|
||||||
|
_state = AnalysisState.loading;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
calculateDistortion();
|
||||||
|
await applyDistortionCorrection();
|
||||||
|
_distortionCorrectionEnabled = true;
|
||||||
|
_state = AnalysisState.success;
|
||||||
|
} catch (e) {
|
||||||
|
_errorMessage = "Erreur de rendu : $e";
|
||||||
|
_state = AnalysisState.error;
|
||||||
|
} finally {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int _calculateShotScore(double x, double y) {
|
int _calculateShotScore(double x, double y) {
|
||||||
if (_targetType == TargetType.concentric) {
|
if (_targetType == TargetType.concentric) {
|
||||||
return _scoreCalculatorService.calculateConcentricScore(
|
return _scoreCalculatorService.calculateConcentricScore(
|
||||||
@@ -238,21 +694,12 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
|
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exporte l'image et le json vers le backend IA.
|
/// Exporte l'image et le json vers le backend IA
|
||||||
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
|
Future<bool> exportToAiBackend() async {
|
||||||
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
|
|
||||||
Future<AiExportResult> exportToAiBackend({
|
|
||||||
String? sessionId,
|
|
||||||
int? distance,
|
|
||||||
String? weapon,
|
|
||||||
}) async {
|
|
||||||
if (_imagePath == null || _targetType == null) {
|
if (_imagePath == null || _targetType == null) {
|
||||||
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
|
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return AiExportResult.error(
|
return false;
|
||||||
code: 'MISSING_DATA',
|
|
||||||
message: "Impossible d'exporter : image ou type de cible manquant.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final service = AiExportService();
|
final service = AiExportService();
|
||||||
@@ -260,24 +707,22 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_state = AnalysisState.loading;
|
_state = AnalysisState.loading;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final result = await service.exportData(
|
final success = await service.exportData(
|
||||||
imagePath: _imagePath!,
|
imagePath: _imagePath!,
|
||||||
sessionId: sessionId ?? 'export',
|
sessionId: 'export',
|
||||||
targetType: _targetType!,
|
targetType: _targetType!,
|
||||||
targetCenterX: _targetCenterX,
|
targetCenterX: _targetCenterX,
|
||||||
targetCenterY: _targetCenterY,
|
targetCenterY: _targetCenterY,
|
||||||
targetRadius: _targetRadius,
|
targetRadius: _targetRadius,
|
||||||
shots: _shots,
|
shots: _shots,
|
||||||
distanceMeters: distance ?? 25,
|
|
||||||
weaponName: weapon ?? 'Unknown',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
_state = AnalysisState.success;
|
||||||
if (!result.isSuccess) {
|
if (!success) {
|
||||||
_errorMessage = result.message;
|
_errorMessage = "Échec de l'export vers le serveur IA.";
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return result;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the session
|
/// Save the session
|
||||||
@@ -362,6 +807,11 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_shots = [];
|
_shots = [];
|
||||||
_scoreResult = null;
|
_scoreResult = null;
|
||||||
_groupingResult = null;
|
_groupingResult = null;
|
||||||
|
_referenceImpacts = [];
|
||||||
|
_learnedCharacteristics = null;
|
||||||
|
_distortionCorrectionEnabled = false;
|
||||||
|
_distortionParams = null;
|
||||||
|
_correctedImagePath = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,4 +833,4 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
/// É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,7 +7,6 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/metric_info_button.dart';
|
|
||||||
import '../../../services/grouping_analyzer_service.dart';
|
import '../../../services/grouping_analyzer_service.dart';
|
||||||
|
|
||||||
class GroupingStats extends StatelessWidget {
|
class GroupingStats extends StatelessWidget {
|
||||||
@@ -44,64 +43,31 @@ class GroupingStats extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const MetricInfoButton(
|
|
||||||
title: 'Groupement',
|
|
||||||
explanations: [
|
|
||||||
MetricExplanation(
|
|
||||||
'Étalement',
|
|
||||||
'Distance entre vos deux impacts les plus éloignés, '
|
|
||||||
'exprimée en % de la largeur de l\'image. Plus c\'est '
|
|
||||||
'bas, plus le groupement est serré.',
|
|
||||||
),
|
|
||||||
MetricExplanation(
|
|
||||||
'Dispersion',
|
|
||||||
'Régularité des impacts autour de leur centre commun '
|
|
||||||
'(écart-type). Plus c\'est bas, plus vos tirs sont '
|
|
||||||
'réguliers.',
|
|
||||||
),
|
|
||||||
MetricExplanation(
|
|
||||||
'Décalage',
|
|
||||||
'Direction du centre de votre groupement par rapport au '
|
|
||||||
'centre de la cible (ex. « Droite » = vos tirs sont '
|
|
||||||
'globalement décalés vers la droite).',
|
|
||||||
),
|
|
||||||
MetricExplanation(
|
|
||||||
'Étoiles',
|
|
||||||
'Qualité globale du groupement, de ★ (à améliorer) à '
|
|
||||||
'★★★★★ (excellent).',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_buildQualityBadge(context),
|
_buildQualityBadge(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
_buildStat(
|
||||||
child: _buildStat(
|
context,
|
||||||
context,
|
'Diametre',
|
||||||
'Étalement',
|
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
||||||
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
icon: Icons.straighten,
|
||||||
icon: Icons.straighten,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
_buildStat(
|
||||||
child: _buildStat(
|
context,
|
||||||
context,
|
'Dispersion',
|
||||||
'Dispersion',
|
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
||||||
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
icon: Icons.scatter_plot,
|
||||||
icon: Icons.scatter_plot,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
_buildStat(
|
||||||
child: _buildStat(
|
context,
|
||||||
context,
|
'Decalage',
|
||||||
'Décalage',
|
offsetDescription,
|
||||||
offsetDescription,
|
icon: Icons.compare_arrows,
|
||||||
icon: Icons.compare_arrows,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -159,14 +125,12 @@ class GroupingStats extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -288,22 +252,22 @@ class GroupingStats extends StatelessWidget {
|
|||||||
|
|
||||||
String _getOffsetDescription(double offsetX, double offsetY) {
|
String _getOffsetDescription(double offsetX, double offsetY) {
|
||||||
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
||||||
return 'Centré';
|
return 'Centre';
|
||||||
}
|
}
|
||||||
|
|
||||||
String vertical = '';
|
String vertical = '';
|
||||||
String horizontal = '';
|
String horizontal = '';
|
||||||
|
|
||||||
if (offsetY < -0.02) {
|
if (offsetY < -0.02) {
|
||||||
vertical = 'Haut';
|
vertical = 'H';
|
||||||
} else if (offsetY > 0.02) {
|
} else if (offsetY > 0.02) {
|
||||||
vertical = 'Bas';
|
vertical = 'B';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offsetX < -0.02) {
|
if (offsetX < -0.02) {
|
||||||
horizontal = 'Gauche';
|
horizontal = 'G';
|
||||||
} else if (offsetX > 0.02) {
|
} else if (offsetX > 0.02) {
|
||||||
horizontal = 'Droite';
|
horizontal = 'D';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/metric_info_button.dart';
|
|
||||||
import '../../../data/models/target_type.dart';
|
import '../../../data/models/target_type.dart';
|
||||||
import '../../../services/score_calculator_service.dart';
|
import '../../../services/score_calculator_service.dart';
|
||||||
|
|
||||||
@@ -17,22 +16,12 @@ class ScoreCard extends StatelessWidget {
|
|||||||
final ScoreResult? scoreResult;
|
final ScoreResult? scoreResult;
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
|
|
||||||
/// Score cumulé de la session (cibles déjà validées + cible en cours).
|
|
||||||
///
|
|
||||||
/// null hors session : le bandeau de session n'est alors pas affiché.
|
|
||||||
final int? sessionTotalScore;
|
|
||||||
|
|
||||||
/// Nombre de cibles comptabilisées dans [sessionTotalScore].
|
|
||||||
final int sessionTargetCount;
|
|
||||||
|
|
||||||
const ScoreCard({
|
const ScoreCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.totalScore,
|
required this.totalScore,
|
||||||
required this.shotCount,
|
required this.shotCount,
|
||||||
this.scoreResult,
|
this.scoreResult,
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
this.sessionTotalScore,
|
|
||||||
this.sessionTargetCount = 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -55,41 +44,6 @@ class ScoreCard extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
if (sessionTotalScore != null) ...[
|
|
||||||
Flexible(child: _buildSessionBadge(context)),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
|
||||||
MetricInfoButton(
|
|
||||||
title: 'Score',
|
|
||||||
explanations: [
|
|
||||||
MetricExplanation(
|
|
||||||
'Total',
|
|
||||||
'Somme des points de tous vos impacts sur CETTE cible, '
|
|
||||||
'sur le maximum possible (nombre d\'impacts × '
|
|
||||||
'$maxScore points).',
|
|
||||||
),
|
|
||||||
const MetricExplanation(
|
|
||||||
'Session',
|
|
||||||
'Score cumulé de toutes les cibles de la session en '
|
|
||||||
'cours, cible affichée comprise.',
|
|
||||||
),
|
|
||||||
const MetricExplanation(
|
|
||||||
'Impacts',
|
|
||||||
'Nombre de tirs détectés sur la cible.',
|
|
||||||
),
|
|
||||||
MetricExplanation(
|
|
||||||
'Moyenne',
|
|
||||||
'Points marqués en moyenne par impact, sur $maxScore.',
|
|
||||||
),
|
|
||||||
const MetricExplanation(
|
|
||||||
'Réussite',
|
|
||||||
'Votre score exprimé en pourcentage du score maximum '
|
|
||||||
'possible. C\'est une mesure du résultat, pas de la '
|
|
||||||
'régularité des tirs.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
@@ -113,12 +67,11 @@ class ScoreCard extends StatelessWidget {
|
|||||||
shotCount > 0
|
shotCount > 0
|
||||||
? (totalScore / shotCount).toStringAsFixed(1)
|
? (totalScore / shotCount).toStringAsFixed(1)
|
||||||
: '-',
|
: '-',
|
||||||
subtitle: '/ $maxScore',
|
|
||||||
),
|
),
|
||||||
if (scoreResult != null)
|
if (scoreResult != null)
|
||||||
_buildScoreStat(
|
_buildScoreStat(
|
||||||
context,
|
context,
|
||||||
'Réussite',
|
'Pourcentage',
|
||||||
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -142,30 +95,6 @@ class ScoreCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bandeau compact rappelant le score total de la session en cours.
|
|
||||||
Widget _buildSessionBadge(BuildContext context) {
|
|
||||||
// Le nombre de cibles n'est rappelé qu'à partir de la deuxième : sur la
|
|
||||||
// première il n'apporte rien et allonge le bandeau pour rien.
|
|
||||||
final cibles = sessionTargetCount > 1 ? ' ($sessionTargetCount cibles)' : '';
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primaryColor.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Session : $sessionTotalScore pts$cibles',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildScoreStat(
|
Widget _buildScoreStat(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String label,
|
String label,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
/// Les anneaux sont répartis proportionnellement.
|
/// Les anneaux sont répartis proportionnellement.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../data/models/target_type.dart';
|
import '../../../data/models/target_type.dart';
|
||||||
|
|
||||||
class TargetCalibration extends StatefulWidget {
|
class TargetCalibration extends StatefulWidget {
|
||||||
@@ -43,15 +44,6 @@ class TargetCalibration extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TargetCalibrationState extends State<TargetCalibration> {
|
class TargetCalibrationState extends State<TargetCalibration> {
|
||||||
/// Bornes du rayon global (mesurées pour laisser de la liberté sans
|
|
||||||
/// débordement incontrôlé).
|
|
||||||
static const double minRadius = 0.3;
|
|
||||||
static const double maxRadius = 0.95;
|
|
||||||
|
|
||||||
/// Bornes de l'espacement : max bridé à 0.70 pour confiner le dernier cercle.
|
|
||||||
static const double minSpacing = 0.01;
|
|
||||||
static const double maxSpacing = 0.70;
|
|
||||||
|
|
||||||
late double _centerX;
|
late double _centerX;
|
||||||
late double _centerY;
|
late double _centerY;
|
||||||
late double _radius;
|
late double _radius;
|
||||||
@@ -91,16 +83,6 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
_initRingRadii();
|
_initRingRadii();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fige et propage la calibration courante vers le provider.
|
|
||||||
///
|
|
||||||
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
|
|
||||||
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
|
|
||||||
/// correspond exactement au dernier réglage validé, sans dépendre d'un
|
|
||||||
/// éventuel rebuild intermédiaire.
|
|
||||||
void commitCalibration() {
|
|
||||||
_notifyChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initRingRadii({bool forceRecalculate = false}) {
|
void _initRingRadii({bool forceRecalculate = false}) {
|
||||||
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
|
// 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) {
|
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
|
||||||
@@ -139,14 +121,7 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||||
shouldReinit = true;
|
shouldReinit = true;
|
||||||
}
|
}
|
||||||
// On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
|
if (widget.initialRingRadii != oldWidget.initialRingRadii && widget.initialRingRadii != null) {
|
||||||
// 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!);
|
_originalRingRadii = List.from(widget.initialRingRadii!);
|
||||||
shouldReinit = true;
|
shouldReinit = true;
|
||||||
}
|
}
|
||||||
@@ -162,116 +137,189 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final size = constraints.biggest;
|
final size = constraints.biggest;
|
||||||
|
|
||||||
// Les réglages (taille / espacement) sont rendus par l'écran hôte
|
return Stack(
|
||||||
// AU-DESSUS de l'image : rien ne vient masquer la cible ici.
|
children: [
|
||||||
return GestureDetector(
|
GestureDetector(
|
||||||
onScaleStart: (details) {
|
onScaleStart: (details) {
|
||||||
_baseRadiusBeforeScale = _radius;
|
_baseRadiusBeforeScale = _radius;
|
||||||
final tapX = details.localFocalPoint.dx / size.width;
|
final tapX = details.localFocalPoint.dx / size.width;
|
||||||
final tapY = details.localFocalPoint.dy / size.height;
|
final tapY = details.localFocalPoint.dy / size.height;
|
||||||
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
||||||
|
|
||||||
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingCenter = true;
|
_isDraggingCenter = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onScaleUpdate: (details) => _onScaleUpdate(details, size),
|
onScaleUpdate: (details) => _onScaleUpdate(details, size),
|
||||||
onScaleEnd: (_) => _onScaleEnd(),
|
onScaleEnd: (_) => _onScaleEnd(),
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
size: size,
|
size: size,
|
||||||
painter: _CalibrationPainter(
|
painter: _CalibrationPainter(
|
||||||
centerX: _centerX,
|
centerX: _centerX,
|
||||||
centerY: _centerY,
|
centerY: _centerY,
|
||||||
radius: _radius,
|
radius: _radius,
|
||||||
innerRadius: _innerRadius,
|
innerRadius: _innerRadius,
|
||||||
ringCount: _ringCount,
|
ringCount: _ringCount,
|
||||||
ringRadii: _ringRadii,
|
ringRadii: _ringRadii,
|
||||||
targetType: widget.targetType,
|
targetType: widget.targetType,
|
||||||
isDraggingCenter: _isDraggingCenter,
|
isDraggingCenter: _isDraggingCenter,
|
||||||
isDraggingRadius: false,
|
isDraggingRadius: false,
|
||||||
isDraggingInnerRadius: false,
|
isDraggingInnerRadius: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
Positioned(
|
||||||
|
top: 10,
|
||||||
|
left: 40,
|
||||||
|
right: 40,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Options d\'espacement avancées',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 28,
|
||||||
|
child: Switch(
|
||||||
|
value: _showEspacement,
|
||||||
|
activeThumbColor: const Color(0xFF00FF00),
|
||||||
|
onChanged: (bool value) {
|
||||||
|
setState(() {
|
||||||
|
_showEspacement = value;
|
||||||
|
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine !
|
||||||
|
if (!value) {
|
||||||
|
_initRingRadii(forceRecalculate: false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Slider pour la taille (toujours visible)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
// SÉCURITÉ : Ouverture mesurée des bornes pour plus de liberté sans débordement incontrôlé
|
||||||
|
value: _radius.clamp(0.3, 0.95),
|
||||||
|
min: 0.3,
|
||||||
|
max: 0.95,
|
||||||
|
activeColor: AppTheme.primaryColor,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_radius = value;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
// CORRECTION : Si le mode avancé n'est pas coché, on applique la taille pure sans détruire le ratio d'origine
|
||||||
|
_initRingRadii(forceRecalculate: _showEspacement);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Affichage conditionnel du slider d'espacement orange
|
||||||
|
if (_showEspacement) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||||
|
const Icon(Icons.compress, color: Colors.white, size: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _currentEspacementRatio,
|
||||||
|
min: 0.01,
|
||||||
|
// SÉCURITÉ : Écartement max bridé à 0.70 pour confiner le dernier cercle
|
||||||
|
max: 0.70,
|
||||||
|
activeColor: Colors.orange,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_currentEspacementRatio = value;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: true);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.expand, color: Colors.white, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// CORRECTION DU BOUTON RESET : Restaure désormais le vrai profil d'usine de l'IA
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
tooltip: 'Réinitialiser l\'espacement',
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// API publique pilotée par l'écran hôte (panneau de réglages hors de l'image)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Espacement courant (rayon du premier anneau, en fraction du rayon global).
|
|
||||||
double get spacingRatio => _currentEspacementRatio;
|
|
||||||
|
|
||||||
/// Mode d'espacement manuel actif ou non.
|
|
||||||
bool get isSpacingModeEnabled => _showEspacement;
|
|
||||||
|
|
||||||
/// Applique une nouvelle taille globale (rayon normalisé).
|
|
||||||
void setRadius(double value) {
|
|
||||||
setState(() {
|
|
||||||
_radius = value.clamp(minRadius, maxRadius);
|
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
|
||||||
// Si le mode avancé n'est pas coché, on applique la taille pure sans
|
|
||||||
// détruire le ratio d'origine.
|
|
||||||
_initRingRadii(forceRecalculate: _showEspacement);
|
|
||||||
});
|
|
||||||
_notifyChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Agrandit / réduit la cible de [deltaPixels] pixels.
|
|
||||||
///
|
|
||||||
/// La conversion utilise la plus petite dimension de [size], exactement comme
|
|
||||||
/// le painter, pour que le pas corresponde bien à un pixel à l'écran.
|
|
||||||
void adjustRadiusByPixels(double deltaPixels, Size size) {
|
|
||||||
final minDim = size.width < size.height ? size.width : size.height;
|
|
||||||
if (minDim <= 0) return;
|
|
||||||
setRadius(_radius + deltaPixels / minDim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active ou non le réglage manuel de l'espacement.
|
|
||||||
///
|
|
||||||
/// À la désactivation, on restaure la configuration d'usine.
|
|
||||||
void setSpacingMode(bool enabled) {
|
|
||||||
setState(() {
|
|
||||||
_showEspacement = enabled;
|
|
||||||
if (!enabled) {
|
|
||||||
_initRingRadii(forceRecalculate: false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_notifyChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applique un nouvel espacement entre les anneaux.
|
|
||||||
void setSpacingRatio(double value) {
|
|
||||||
setState(() {
|
|
||||||
_currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
|
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
|
||||||
_initRingRadii(forceRecalculate: true);
|
|
||||||
});
|
|
||||||
_notifyChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restaure le vrai profil d'usine détecté sur l'image.
|
|
||||||
void resetSpacing() {
|
|
||||||
setState(() {
|
|
||||||
if (_originalRingRadii != null) {
|
|
||||||
_initRingRadii(forceRecalculate: false);
|
|
||||||
if (_ringRadii.isNotEmpty) {
|
|
||||||
_currentEspacementRatio = _ringRadii.first;
|
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_currentEspacementRatio = 0.1;
|
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
|
||||||
_initRingRadii(forceRecalculate: true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_notifyChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildDirectionalControls(BuildContext context, Size size) {
|
Widget buildDirectionalControls(BuildContext context, Size size) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -280,52 +328,24 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white10),
|
border: Border.all(color: Colors.white10),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Réduit la cible d'un pixel.
|
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
||||||
_buildSizeButton('−', () => adjustRadiusByPixels(-1, size)),
|
Row(
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
|
||||||
Row(
|
const SizedBox(width: 40),
|
||||||
mainAxisSize: MainAxisSize.min,
|
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
|
||||||
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),
|
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
|
||||||
// Agrandit la cible d'un pixel.
|
|
||||||
_buildSizeButton('+', () => adjustRadiusByPixels(1, size)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bouton − / + de part et d'autre de la croix : ajuste la TAILLE de la cible.
|
|
||||||
Widget _buildSizeButton(String sign, VoidCallback onPressed) {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
|
||||||
child: IconButton(
|
|
||||||
icon: Text(
|
|
||||||
sign,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
onPressed: onPressed,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
|
||||||
tooltip: sign == '+' ? 'Agrandir la cible' : 'Réduire la cible',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||||
@@ -361,7 +381,7 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
|
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (details.pointerCount == 2) {
|
if (details.pointerCount == 2) {
|
||||||
_radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius);
|
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95);
|
||||||
_innerRadius = _radius * _currentEspacementRatio;
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
_initRingRadii(forceRecalculate: _showEspacement);
|
_initRingRadii(forceRecalculate: _showEspacement);
|
||||||
} else if (_isDraggingCenter) {
|
} else if (_isDraggingCenter) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/// Overlay visuel de la cible.
|
/// Overlay visuel de la cible.
|
||||||
///
|
///
|
||||||
/// Dessine les anneaux de la cible, les impacts et le cercle de groupement.
|
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement
|
||||||
/// Gère uniquement la SÉLECTION d'impacts existants (tap sur un impact).
|
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout
|
||||||
/// L'AJOUT d'un impact est délégué à l'écran parent pour éviter tout conflit
|
/// d'impacts et la sélection d'impacts existants.
|
||||||
/// de gestes avec le zoom/pan de l'InteractiveViewer.
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -20,9 +19,11 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
final int ringCount;
|
final int ringCount;
|
||||||
final List<double>? ringRadii;
|
final List<double>? ringRadii;
|
||||||
final void Function(Shot shot)? onShotTapped;
|
final void Function(Shot shot)? onShotTapped;
|
||||||
|
final void Function(double x, double y)? onAddShot;
|
||||||
final double? groupingCenterX;
|
final double? groupingCenterX;
|
||||||
final double? groupingCenterY;
|
final double? groupingCenterY;
|
||||||
final double? groupingDiameter;
|
final double? groupingDiameter;
|
||||||
|
final List<Shot>? referenceImpacts;
|
||||||
final double zoomScale;
|
final double zoomScale;
|
||||||
final bool showRings;
|
final bool showRings;
|
||||||
|
|
||||||
@@ -36,82 +37,87 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
this.ringCount = 10,
|
this.ringCount = 10,
|
||||||
this.ringRadii,
|
this.ringRadii,
|
||||||
this.onShotTapped,
|
this.onShotTapped,
|
||||||
|
this.onAddShot,
|
||||||
this.groupingCenterX,
|
this.groupingCenterX,
|
||||||
this.groupingCenterY,
|
this.groupingCenterY,
|
||||||
this.groupingDiameter,
|
this.groupingDiameter,
|
||||||
|
this.referenceImpacts,
|
||||||
this.zoomScale = 1.0,
|
this.zoomScale = 1.0,
|
||||||
this.showRings = false,
|
this.showRings = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// IMPORTANT : plus de GestureDetector global ici.
|
return LayoutBuilder(
|
||||||
// L'ancien GestureDetector (onTapUp couvrant toute la surface, en
|
builder: (context, constraints) {
|
||||||
// HitTestBehavior.translucent) volait les pointeurs au pinch de
|
return GestureDetector(
|
||||||
// l'InteractiveViewer parent et rendait le zoom capricieux.
|
behavior: HitTestBehavior.translucent,
|
||||||
//
|
onTapUp: (details) {
|
||||||
// Désormais :
|
if (onAddShot != null) {
|
||||||
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
|
// Utiliser les constraints pour un calcul précis
|
||||||
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
|
final relX = details.localPosition.dx / constraints.maxWidth;
|
||||||
// zones de tap localisées (deferToChild) placées sur chaque impact —
|
final relY = details.localPosition.dy / constraints.maxHeight;
|
||||||
// et UNIQUEMENT si [onShotTapped] est fourni. Sans callback, aucune zone
|
onAddShot!(relX, relY);
|
||||||
// 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(
|
child: CustomPaint(
|
||||||
ignoring: false,
|
painter: _TargetOverlayPainter(
|
||||||
child: CustomPaint(
|
shots: shots,
|
||||||
painter: _TargetOverlayPainter(
|
targetCenterX: targetCenterX,
|
||||||
shots: shots,
|
targetCenterY: targetCenterY,
|
||||||
targetCenterX: targetCenterX,
|
targetRadius: targetRadius,
|
||||||
targetCenterY: targetCenterY,
|
targetType: targetType,
|
||||||
targetRadius: targetRadius,
|
ringCount: ringCount,
|
||||||
targetType: targetType,
|
ringRadii: ringRadii,
|
||||||
ringCount: ringCount,
|
groupingCenterX: groupingCenterX,
|
||||||
ringRadii: ringRadii,
|
groupingCenterY: groupingCenterY,
|
||||||
groupingCenterX: groupingCenterX,
|
groupingDiameter: groupingDiameter,
|
||||||
groupingCenterY: groupingCenterY,
|
referenceImpacts: referenceImpacts,
|
||||||
groupingDiameter: groupingDiameter,
|
zoomScale: zoomScale,
|
||||||
zoomScale: zoomScale,
|
showRings: showRings,
|
||||||
showRings: showRings,
|
),
|
||||||
),
|
child: Stack(
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final onTapped = onShotTapped;
|
|
||||||
if (onTapped == null) return const SizedBox.expand();
|
|
||||||
return Stack(
|
|
||||||
children: shots.map((shot) {
|
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 Positioned(
|
return Positioned(
|
||||||
left: x - halfTapSize,
|
left: 0,
|
||||||
top: y - halfTapSize,
|
top: 0,
|
||||||
child: GestureDetector(
|
right: 0,
|
||||||
// deferToChild : ne capte le toucher QUE sur la zone du
|
bottom: 0,
|
||||||
// Container (un cercle opaque au hit-test), pas ailleurs.
|
child: LayoutBuilder(
|
||||||
// Le reste de la surface reste donc disponible pour le
|
builder: (context, innerConstraints) {
|
||||||
// pinch/pan de l'InteractiveViewer.
|
final x = shot.x * innerConstraints.maxWidth;
|
||||||
behavior: HitTestBehavior.deferToChild,
|
final y = shot.y * innerConstraints.maxHeight;
|
||||||
onTap: () => onTapped(shot),
|
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran)
|
||||||
child: Container(
|
final tapSize = 30 / zoomScale;
|
||||||
width: tapSize,
|
final halfTapSize = tapSize / 2;
|
||||||
height: tapSize,
|
return Stack(
|
||||||
decoration: const BoxDecoration(
|
children: [
|
||||||
// Opaque pour le hit-test (couleur transparente visuellement
|
Positioned(
|
||||||
// mais non nulle), pour que le tap soit bien capté ici.
|
left: x - halfTapSize,
|
||||||
color: Color(0x01000000),
|
top: y - halfTapSize,
|
||||||
shape: BoxShape.circle,
|
child: GestureDetector(
|
||||||
),
|
behavior: HitTestBehavior.translucent,
|
||||||
),
|
onTap: () => onShotTapped?.call(shot),
|
||||||
|
child: Container(
|
||||||
|
width: tapSize,
|
||||||
|
height: tapSize,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +133,7 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
final double? groupingCenterX;
|
final double? groupingCenterX;
|
||||||
final double? groupingCenterY;
|
final double? groupingCenterY;
|
||||||
final double? groupingDiameter;
|
final double? groupingDiameter;
|
||||||
|
final List<Shot>? referenceImpacts;
|
||||||
final double zoomScale;
|
final double zoomScale;
|
||||||
final bool showRings;
|
final bool showRings;
|
||||||
|
|
||||||
@@ -141,6 +148,7 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
this.groupingCenterX,
|
this.groupingCenterX,
|
||||||
this.groupingCenterY,
|
this.groupingCenterY,
|
||||||
this.groupingDiameter,
|
this.groupingDiameter,
|
||||||
|
this.referenceImpacts,
|
||||||
this.zoomScale = 1.0,
|
this.zoomScale = 1.0,
|
||||||
this.showRings = false,
|
this.showRings = false,
|
||||||
});
|
});
|
||||||
@@ -161,6 +169,13 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
_drawImpact(canvas, size, shot);
|
_drawImpact(canvas, size, shot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw reference impacts (with different color)
|
||||||
|
if (referenceImpacts != null) {
|
||||||
|
for (final ref in referenceImpacts!) {
|
||||||
|
_drawReferenceImpact(canvas, size, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawTargetCenter(Canvas canvas, Size size) {
|
void _drawTargetCenter(Canvas canvas, Size size) {
|
||||||
@@ -196,8 +211,8 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
final prevMultiplier = i == 0
|
final prevMultiplier = i == 0
|
||||||
? 0.0
|
? 0.0
|
||||||
: (ringRadii != null && ringRadii!.length == ringCount)
|
: (ringRadii != null && ringRadii!.length == ringCount)
|
||||||
? ringRadii![i - 1]
|
? ringRadii![i - 1]
|
||||||
: i / ringCount;
|
: i / ringCount;
|
||||||
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
|
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
|
||||||
final score = 10 - i;
|
final score = 10 - i;
|
||||||
|
|
||||||
@@ -277,17 +292,16 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
final strokeWidth = 3 / zoomScale;
|
final strokeWidth = 3 / zoomScale;
|
||||||
final fontSize = 10 / zoomScale;
|
final fontSize = 10 / zoomScale;
|
||||||
|
|
||||||
// Draw outer circle (white outline for visibility) — gardé OPAQUE pour
|
// Draw outer circle (white outline for visibility)
|
||||||
// bien repérer le centre même quand le remplissage est transparent.
|
|
||||||
final outlinePaint = Paint()
|
final outlinePaint = Paint()
|
||||||
..color = AppTheme.impactOutlineColor
|
..color = AppTheme.impactOutlineColor
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = strokeWidth;
|
..strokeWidth = strokeWidth;
|
||||||
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
||||||
|
|
||||||
// Draw impact marker — TRANSPARENCE 30% pour voir l'impact réel derrière
|
// Draw impact marker
|
||||||
final impactPaint = Paint()
|
final impactPaint = Paint()
|
||||||
..color = AppTheme.impactColor.withValues(alpha: 0.3)
|
..color = AppTheme.impactColor
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
|
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
|
||||||
|
|
||||||
@@ -310,6 +324,48 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) {
|
||||||
|
final x = ref.x * size.width;
|
||||||
|
final y = ref.y * size.height;
|
||||||
|
|
||||||
|
// Tailles fixes divisées par le zoom pour rester constantes à l'écran
|
||||||
|
final outerRadius = 12 / zoomScale;
|
||||||
|
final innerRadius = 10 / zoomScale;
|
||||||
|
final strokeWidth = 3 / zoomScale;
|
||||||
|
final fontSize = 12 / zoomScale;
|
||||||
|
|
||||||
|
// Draw outer circle (white outline for visibility)
|
||||||
|
final outlinePaint = Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = strokeWidth;
|
||||||
|
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
||||||
|
|
||||||
|
// Draw reference marker (purple)
|
||||||
|
final refPaint = Paint()
|
||||||
|
..color = Colors.deepPurple
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
canvas.drawCircle(Offset(x, y), innerRadius, refPaint);
|
||||||
|
|
||||||
|
// Draw "R" to indicate reference
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: 'R',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
);
|
||||||
|
textPainter.layout();
|
||||||
|
textPainter.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
|
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
|
||||||
return shots != oldDelegate.shots ||
|
return shots != oldDelegate.shots ||
|
||||||
@@ -321,7 +377,8 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
groupingCenterX != oldDelegate.groupingCenterX ||
|
groupingCenterX != oldDelegate.groupingCenterX ||
|
||||||
groupingCenterY != oldDelegate.groupingCenterY ||
|
groupingCenterY != oldDelegate.groupingCenterY ||
|
||||||
groupingDiameter != oldDelegate.groupingDiameter ||
|
groupingDiameter != oldDelegate.groupingDiameter ||
|
||||||
|
referenceImpacts != oldDelegate.referenceImpacts ||
|
||||||
zoomScale != oldDelegate.zoomScale ||
|
zoomScale != oldDelegate.zoomScale ||
|
||||||
showRings != oldDelegate.showRings;
|
showRings != oldDelegate.showRings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,28 @@ import '../analysis/analysis_screen.dart';
|
|||||||
import 'widgets/crop_overlay.dart';
|
import 'widgets/crop_overlay.dart';
|
||||||
|
|
||||||
class CropScreen extends StatefulWidget {
|
class CropScreen extends StatefulWidget {
|
||||||
|
/// Image affichée/recadrée dans cet écran. Au tout premier passage, c'est
|
||||||
|
/// aussi l'image originale. Lors des allers-retours, on recharge toujours
|
||||||
|
/// l'ORIGINAL ici (jamais une image déjà recadrée) pour éviter le zoom
|
||||||
|
/// cumulatif.
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
|
|
||||||
|
/// Chemin de l'image ORIGINALE (jamais recadrée). Si null, [imagePath] est
|
||||||
|
/// considéré comme l'original (premier passage depuis la caméra/galerie).
|
||||||
|
final String? originalImagePath;
|
||||||
|
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
final double? initialScale;
|
|
||||||
final Offset? initialOffset;
|
/// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement
|
||||||
|
/// PAS restauré : on repart toujours de l'image entière.
|
||||||
|
final double? initialRotation;
|
||||||
|
|
||||||
const CropScreen({
|
const CropScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imagePath,
|
required this.imagePath,
|
||||||
|
this.originalImagePath,
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
this.initialScale,
|
this.initialRotation,
|
||||||
this.initialOffset,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -27,11 +38,6 @@ class CropScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CropScreenState extends State<CropScreen> {
|
class _CropScreenState extends State<CropScreen> {
|
||||||
// Bornes et pas de la rotation, partagés par la jauge et les boutons − / +.
|
|
||||||
static const double _minRotation = -15.0;
|
|
||||||
static const double _maxRotation = 15.0;
|
|
||||||
static const double _rotationStep = 0.1;
|
|
||||||
|
|
||||||
final ImageCropService _cropService = ImageCropService();
|
final ImageCropService _cropService = ImageCropService();
|
||||||
|
|
||||||
// États de transformation
|
// États de transformation
|
||||||
@@ -50,14 +56,20 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
late Size _viewportSize;
|
late Size _viewportSize;
|
||||||
late double _cropSize;
|
late double _cropSize;
|
||||||
|
|
||||||
|
/// L'image effectivement travaillée par cet écran : toujours l'originale.
|
||||||
|
String get _workingImagePath =>
|
||||||
|
widget.originalImagePath ?? widget.imagePath;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// On restaure uniquement la rotation (pas le zoom).
|
||||||
|
_rotation = widget.initialRotation ?? 0.0;
|
||||||
_loadImageInfo();
|
_loadImageInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadImageInfo() async {
|
Future<void> _loadImageInfo() async {
|
||||||
final file = File(widget.imagePath);
|
final file = File(_workingImagePath);
|
||||||
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -106,9 +118,24 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
|
// Zone interactive de crop
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// TEXTE D'AIDE AJUSTÉ
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@@ -116,54 +143,21 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Flexible(
|
Text(
|
||||||
child: Text(
|
'Alignez et pivotez la cible sur la croix',
|
||||||
'Zoomer au maximum puis aligner votre cible',
|
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
|
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Zone interactive de crop, CARRÉE. La photo la remplit entièrement
|
|
||||||
// (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre
|
|
||||||
// est récupérable en déplaçant/zoomant. La sortie d'analyse reste
|
|
||||||
// carrée, donc la cible n'est pas déformée.
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Center(
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: 1.0,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.white10),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel,
|
|
||||||
// encadrée par les deux boutons de rotation fine.
|
|
||||||
_buildDirectionalPad(),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
||||||
@@ -186,13 +180,12 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Les icônes de sens de rotation ont rejoint les boutons − / +
|
const Icon(Icons.rotate_left, color: Colors.white38, size: 20),
|
||||||
// de la croix directionnelle.
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Slider(
|
child: Slider(
|
||||||
value: _rotation,
|
value: _rotation,
|
||||||
min: _minRotation,
|
min: -15.0,
|
||||||
max: _maxRotation,
|
max: 15.0,
|
||||||
divisions: 300,
|
divisions: 300,
|
||||||
label: '${_rotation.toStringAsFixed(1)}°',
|
label: '${_rotation.toStringAsFixed(1)}°',
|
||||||
activeColor: const Color(0xFF1A73E8),
|
activeColor: const Color(0xFF1A73E8),
|
||||||
@@ -204,6 +197,7 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Icon(Icons.rotate_right, color: Colors.white38, size: 20),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
||||||
@@ -261,11 +255,24 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
|
||||||
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
|
// FIX : On calcule d'abord la taille de la photo affichée avant de définir la taille du cadre vert !
|
||||||
// visée = toute la zone visible → aucun bord noir autour du cadre.
|
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0;
|
||||||
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
|
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||||
|
|
||||||
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
|
double displayWidth, displayHeight;
|
||||||
|
if (imageAspect > viewportAspect) {
|
||||||
|
displayWidth = _viewportSize.width;
|
||||||
|
displayHeight = _viewportSize.width / imageAspect;
|
||||||
|
} else {
|
||||||
|
displayHeight = _viewportSize.height;
|
||||||
|
displayWidth = _viewportSize.height * imageAspect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum),
|
||||||
|
// au lieu des 85% précédents qui rognaient inutilement les bords.
|
||||||
|
_cropSize = math.min(displayWidth, displayHeight);
|
||||||
|
|
||||||
|
if (_scale == 1.0 && _offset == Offset.zero) {
|
||||||
_initializeImagePosition();
|
_initializeImagePosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,12 +287,12 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
child: Transform(
|
child: Transform(
|
||||||
transform: Matrix4.identity()
|
transform: Matrix4.identity()
|
||||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
||||||
..scaleByDouble(_scale, _scale, _scale, 1.0)
|
..scale(_scale, _scale)
|
||||||
..rotateZ(_rotation * (math.pi / 180)),
|
..rotateZ(_rotation * (math.pi / 180)),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Image.file(
|
child: Image.file(
|
||||||
File(widget.imagePath),
|
File(_workingImagePath),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.contain,
|
||||||
width: _viewportSize.width,
|
width: _viewportSize.width,
|
||||||
height: _viewportSize.height,
|
height: _viewportSize.height,
|
||||||
),
|
),
|
||||||
@@ -302,14 +309,14 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
color: const Color(0xFF00FF00).withOpacity(0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
color: const Color(0xFF00FF00).withOpacity(0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -356,124 +363,23 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
void _initializeImagePosition() {
|
void _initializeImagePosition() {
|
||||||
if (_imageSize == null) return;
|
if (_imageSize == null) return;
|
||||||
|
|
||||||
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
|
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||||
_scale = widget.initialScale ?? 1.0;
|
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||||
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
|
|
||||||
|
|
||||||
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
|
// 1. Calcul strict de la taille de l'image affichée en BoxFit.contain
|
||||||
if (widget.initialOffset != null) {
|
double displayWidth, displayHeight;
|
||||||
_offset = widget.initialOffset!;
|
if (imageAspect > viewportAspect) {
|
||||||
|
displayWidth = _viewportSize.width;
|
||||||
|
displayHeight = _viewportSize.width / imageAspect;
|
||||||
} else {
|
} else {
|
||||||
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
|
displayHeight = _viewportSize.height;
|
||||||
|
displayWidth = _viewportSize.height * imageAspect;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
|
// On repart TOUJOURS de l'image entière, centrée, sans zoom :
|
||||||
// Les boutons « − » et « + » de part et d'autre pivotent l'image de 0,1°.
|
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours.
|
||||||
Widget _buildDirectionalPad() {
|
_scale = 1.0;
|
||||||
return Row(
|
_offset = Offset.zero;
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
_buildRotationButton(
|
|
||||||
icon: Icons.rotate_left,
|
|
||||||
sign: '−',
|
|
||||||
iconFirst: true,
|
|
||||||
tooltip: 'Pivoter vers la gauche',
|
|
||||||
onPressed: () => _rotateBy(-_rotationStep),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_buildCropDirButton(Icons.keyboard_arrow_up, () => _nudge(0, -1)),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_buildCropDirButton(Icons.keyboard_arrow_left, () => _nudge(-1, 0)),
|
|
||||||
const SizedBox(width: 28),
|
|
||||||
_buildCropDirButton(Icons.keyboard_arrow_right, () => _nudge(1, 0)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
_buildCropDirButton(Icons.keyboard_arrow_down, () => _nudge(0, 1)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
_buildRotationButton(
|
|
||||||
icon: Icons.rotate_right,
|
|
||||||
sign: '+',
|
|
||||||
iconFirst: false,
|
|
||||||
tooltip: 'Pivoter vers la droite',
|
|
||||||
onPressed: () => _rotateBy(_rotationStep),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(6)),
|
|
||||||
margin: const EdgeInsets.all(2),
|
|
||||||
child: IconButton(
|
|
||||||
icon: Icon(icon, color: Colors.white, size: 22),
|
|
||||||
onPressed: onPressed,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bouton de rotation : l'icône de sens est accolée au signe − / +.
|
|
||||||
Widget _buildRotationButton({
|
|
||||||
required IconData icon,
|
|
||||||
required String sign,
|
|
||||||
required bool iconFirst,
|
|
||||||
required String tooltip,
|
|
||||||
required VoidCallback onPressed,
|
|
||||||
}) {
|
|
||||||
final content = [
|
|
||||||
Icon(icon, color: Colors.white70, size: 20),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
sign,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Tooltip(
|
|
||||||
message: tooltip,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.black54,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onPressed,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: iconFirst ? content : content.reversed.toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _nudge(double dx, double dy) {
|
|
||||||
setState(() {
|
|
||||||
_offset = _offset + Offset(dx, dy);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pivote l'image de [delta] degrés, dans les mêmes bornes que la jauge.
|
|
||||||
///
|
|
||||||
/// La valeur est arrondie au dixième pour rester calée sur les crans de la
|
|
||||||
/// jauge (300 divisions sur 30°) et sur l'affichage.
|
|
||||||
void _rotateBy(double delta) {
|
|
||||||
setState(() {
|
|
||||||
final value = (_rotation + delta).clamp(_minRotation, _maxRotation);
|
|
||||||
_rotation = (value * 10).roundToDouble() / 10;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScaleStart(ScaleStartDetails details) {
|
void _onScaleStart(ScaleStartDetails details) {
|
||||||
@@ -493,26 +399,24 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
Future<void> _onCropConfirm() async {
|
Future<void> _onCropConfirm() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
|
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer
|
||||||
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
|
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants
|
||||||
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
|
final savedScale = _scale;
|
||||||
final double displayPerSourcePx = math.max(
|
final savedOffset = _offset;
|
||||||
_viewportSize.width / _imageSize!.width,
|
_scale = 1.0;
|
||||||
_viewportSize.height / _imageSize!.height,
|
_offset = Offset.zero;
|
||||||
);
|
|
||||||
|
|
||||||
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
|
final cropRect = _calculateCropRect();
|
||||||
// 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.
|
// On restaure pour l'affichage (au cas où on revient en arrière)
|
||||||
final croppedImagePath = await _cropService.cropViewport(
|
_scale = savedScale;
|
||||||
sourcePath: widget.imagePath,
|
_offset = savedOffset;
|
||||||
offsetDx: _offset.dx,
|
|
||||||
offsetDy: _offset.dy,
|
// Le crop est TOUJOURS calculé sur l'image originale, jamais sur un
|
||||||
displayPerSourcePx: displayPerSourcePx,
|
// résultat déjà recadré.
|
||||||
cropSizeDisplay: _cropSize,
|
final croppedImagePath = await _cropService.cropToSquare(
|
||||||
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
|
_workingImagePath,
|
||||||
// il n'agrandit pas le rendu de sortie.
|
cropRect,
|
||||||
zoomScale: _scale,
|
|
||||||
rotationDegrees: _rotation,
|
rotationDegrees: _rotation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -526,16 +430,13 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => AnalysisScreen(
|
builder: (_) => AnalysisScreen(
|
||||||
imagePath: croppedImagePath,
|
imagePath: croppedImagePath,
|
||||||
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
|
// On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir
|
||||||
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
|
// revenir au centrage sans jamais re-recadrer le résultat.
|
||||||
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
|
originalImagePath: _workingImagePath,
|
||||||
originalImagePath: widget.imagePath,
|
cropRotation: _rotation,
|
||||||
targetType: widget.targetType,
|
targetType: widget.targetType,
|
||||||
initialCenterX: targetCenterX,
|
initialCenterX: targetCenterX,
|
||||||
initialCenterY: targetCenterY,
|
initialCenterY: targetCenterY,
|
||||||
cropScale: 1.0,
|
|
||||||
cropOffset: Offset.zero,
|
|
||||||
cropRotation: 0.0,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -549,4 +450,43 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CropRect _calculateCropRect() {
|
||||||
|
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
||||||
|
|
||||||
|
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||||
|
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||||
|
|
||||||
|
double displayWidth, displayHeight;
|
||||||
|
if (imageAspect > viewportAspect) {
|
||||||
|
displayWidth = _viewportSize.width;
|
||||||
|
displayHeight = _viewportSize.width / imageAspect;
|
||||||
|
} else {
|
||||||
|
displayHeight = _viewportSize.height;
|
||||||
|
displayWidth = _viewportSize.height * imageAspect;
|
||||||
|
}
|
||||||
|
|
||||||
|
final scaledWidth = displayWidth * _scale;
|
||||||
|
final scaledHeight = displayHeight * _scale;
|
||||||
|
|
||||||
|
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
||||||
|
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
||||||
|
|
||||||
|
final imageLeft = imageCenterX - scaledWidth / 2;
|
||||||
|
final imageTop = imageCenterY - scaledHeight / 2;
|
||||||
|
|
||||||
|
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
||||||
|
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
||||||
|
|
||||||
|
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
||||||
|
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
||||||
|
final relCropWidth = _cropSize / scaledWidth;
|
||||||
|
final relCropHeight = _cropSize / scaledHeight;
|
||||||
|
|
||||||
|
return CropRect(
|
||||||
|
x: relCropLeft.clamp(0.0, 1.0),
|
||||||
|
y: relCropTop.clamp(0.0, 1.0),
|
||||||
|
width: relCropWidth.clamp(0.0, 1.0),
|
||||||
|
height: relCropHeight.clamp(0.0, 1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/weapon_type_icon.dart';
|
|
||||||
import '../../data/models/weapon.dart';
|
import '../../data/models/weapon.dart';
|
||||||
import '../../data/models/maintenance.dart';
|
import '../../data/models/maintenance.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
@@ -20,7 +19,6 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
late Weapon _weapon;
|
late Weapon _weapon;
|
||||||
List<MaintenanceEntry> _maintenance = [];
|
List<MaintenanceEntry> _maintenance = [];
|
||||||
int _totalRounds = 0;
|
int _totalRounds = 0;
|
||||||
int _sessionCount = 0;
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,12 +32,10 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final history = await repository.getMaintenanceHistory(_weapon.id);
|
final history = await repository.getMaintenanceHistory(_weapon.id);
|
||||||
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
||||||
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_maintenance = history;
|
_maintenance = history;
|
||||||
_totalRounds = rounds;
|
_totalRounds = rounds;
|
||||||
_sessionCount = sessions;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -53,7 +49,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
onPressed: _showEditWeaponDialog,
|
onPressed: () => _showEditWeaponDialog(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -75,7 +71,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: _showAddMaintenanceDialog,
|
onPressed: () => _showAddMaintenanceDialog(context),
|
||||||
label: const Text('Entretien'),
|
label: const Text('Entretien'),
|
||||||
icon: const Icon(Icons.build),
|
icon: const Icon(Icons.build),
|
||||||
),
|
),
|
||||||
@@ -97,7 +93,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _buildStatCard(
|
child: _buildStatCard(
|
||||||
'Sessions',
|
'Sessions',
|
||||||
_sessionCount.toString(),
|
'N/A',
|
||||||
Icons.history,
|
Icons.history,
|
||||||
Colors.orange,
|
Colors.orange,
|
||||||
),
|
),
|
||||||
@@ -132,26 +128,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_buildInfoRow('Modèle', _weapon.name),
|
_buildInfoRow('Modèle', _weapon.name),
|
||||||
Padding(
|
_buildInfoRow('Type', _weapon.type.displayName),
|
||||||
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('Calibre', _weapon.caliber),
|
||||||
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
|
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
|
||||||
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
|
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
|
||||||
@@ -172,24 +149,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
|
|
||||||
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.primaryColor,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
minimumSize: const Size(36, 36),
|
|
||||||
),
|
|
||||||
onPressed: _showEditAccessoriesDialog,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
||||||
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
|
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
|
||||||
@@ -243,7 +203,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
title: Text(entry.type.displayName),
|
title: Text(entry.type.displayName),
|
||||||
subtitle: Text(entry.description),
|
subtitle: Text(entry.description),
|
||||||
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
|
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
|
||||||
onLongPress: () => _confirmDeleteMaintenance(entry),
|
onLongPress: () => _confirmDeleteMaintenance(context, entry),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -272,36 +232,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
|
void _showEditWeaponDialog(BuildContext context) async {
|
||||||
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
|
|
||||||
// (ex: Modérateur) se retrouve caché sous le clavier.
|
|
||||||
Widget _autoScrollOnFocus(Widget child) {
|
|
||||||
return Builder(
|
|
||||||
builder: (context) => Focus(
|
|
||||||
canRequestFocus: false,
|
|
||||||
skipTraversal: true,
|
|
||||||
onFocusChange: (hasFocus) {
|
|
||||||
if (!hasFocus) return;
|
|
||||||
// On attend que le clavier ait fini de redimensionner la vue.
|
|
||||||
Future.delayed(const Duration(milliseconds: 300), () {
|
|
||||||
if (context.mounted) {
|
|
||||||
Scrollable.ensureVisible(
|
|
||||||
context,
|
|
||||||
alignment: 0.5,
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showEditWeaponDialog() async {
|
|
||||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
final nameController = TextEditingController(text: _weapon.name);
|
final nameController = TextEditingController(text: _weapon.name);
|
||||||
final caliberController = TextEditingController(text: _weapon.caliber);
|
final caliberController = TextEditingController(text: _weapon.caliber);
|
||||||
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
|
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
|
||||||
@@ -322,54 +253,41 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_autoScrollOnFocus(TextField(
|
TextField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
||||||
)),
|
),
|
||||||
_autoScrollOnFocus(TextField(
|
TextField(
|
||||||
controller: customNameController,
|
controller: customNameController,
|
||||||
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
||||||
)),
|
),
|
||||||
DropdownButtonFormField<WeaponType>(
|
DropdownButtonFormField<WeaponType>(
|
||||||
initialValue: selectedType,
|
value: selectedType,
|
||||||
decoration: const InputDecoration(labelText: 'Type'),
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
items: WeaponType.values
|
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
.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!),
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
),
|
),
|
||||||
_autoScrollOnFocus(TextField(
|
TextField(
|
||||||
controller: caliberController,
|
controller: caliberController,
|
||||||
decoration: const InputDecoration(labelText: 'Calibre'),
|
decoration: const InputDecoration(labelText: 'Calibre'),
|
||||||
)),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
_autoScrollOnFocus(TextField(
|
TextField(
|
||||||
controller: opticController,
|
controller: opticController,
|
||||||
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
||||||
)),
|
),
|
||||||
_autoScrollOnFocus(TextField(
|
TextField(
|
||||||
controller: silencerController,
|
controller: silencerController,
|
||||||
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
||||||
)),
|
),
|
||||||
_autoScrollOnFocus(TextField(
|
TextField(
|
||||||
controller: triggerController,
|
controller: triggerController,
|
||||||
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
||||||
)),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
_autoScrollOnFocus(Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -387,12 +305,12 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)),
|
),
|
||||||
_autoScrollOnFocus(TextField(
|
TextField(
|
||||||
controller: notesController,
|
controller: notesController,
|
||||||
decoration: const InputDecoration(labelText: 'Notes'),
|
decoration: const InputDecoration(labelText: 'Notes'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
)),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -417,126 +335,18 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
trigger: triggerController.text.isEmpty ? null : triggerController.text,
|
trigger: triggerController.text.isEmpty ? null : triggerController.text,
|
||||||
customName: customNameController.text.isEmpty ? null : customNameController.text,
|
customName: customNameController.text.isEmpty ? null : customNameController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.updateWeapon(updatedWeapon);
|
await repository.updateWeapon(updatedWeapon);
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_weapon = updatedWeapon;
|
_weapon = updatedWeapon;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
|
void _showAddMaintenanceDialog(BuildContext context) async {
|
||||||
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
|
|
||||||
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
|
|
||||||
// pouvoir comparer les scores selon la configuration utilisée.
|
|
||||||
void _showEditAccessoriesDialog() async {
|
|
||||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
final opticController = TextEditingController(text: _weapon.optic);
|
|
||||||
final silencerController = TextEditingController(text: _weapon.silencer);
|
|
||||||
final triggerController = TextEditingController(text: _weapon.trigger);
|
|
||||||
final customNameController = TextEditingController(text: _weapon.customName);
|
|
||||||
|
|
||||||
final result = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Modifier les accessoires'),
|
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
|
|
||||||
'pour pouvoir comparer les scores selon la configuration.',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_autoScrollOnFocus(TextField(
|
|
||||||
controller: opticController,
|
|
||||||
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
|
||||||
)),
|
|
||||||
_autoScrollOnFocus(TextField(
|
|
||||||
controller: silencerController,
|
|
||||||
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
|
||||||
)),
|
|
||||||
_autoScrollOnFocus(TextField(
|
|
||||||
controller: triggerController,
|
|
||||||
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
|
||||||
)),
|
|
||||||
_autoScrollOnFocus(TextField(
|
|
||||||
controller: customNameController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Surnom de la configuration',
|
|
||||||
hintText: 'ex: Echelon + Holosun',
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
|
||||||
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != true || !mounted) return;
|
|
||||||
|
|
||||||
final newOptic = opticController.text.isEmpty ? null : opticController.text;
|
|
||||||
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
|
|
||||||
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
|
|
||||||
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
|
|
||||||
|
|
||||||
// Rien à faire si aucun accessoire n'a changé.
|
|
||||||
final unchanged = newOptic == _weapon.optic &&
|
|
||||||
newSilencer == _weapon.silencer &&
|
|
||||||
newTrigger == _weapon.trigger;
|
|
||||||
if (unchanged) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
|
|
||||||
if (newCustomName == null) {
|
|
||||||
final accessories = [newOptic, newSilencer, newTrigger]
|
|
||||||
.where((a) => a != null && a.isNotEmpty)
|
|
||||||
.join(' / ');
|
|
||||||
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
|
|
||||||
}
|
|
||||||
|
|
||||||
final newWeapon = await repository.addWeapon(
|
|
||||||
name: _weapon.name,
|
|
||||||
type: _weapon.type,
|
|
||||||
caliber: _weapon.caliber,
|
|
||||||
magazineCount: _weapon.magazineCount,
|
|
||||||
magazineCapacity: _weapon.magazineCapacity,
|
|
||||||
notes: _weapon.notes,
|
|
||||||
optic: newOptic,
|
|
||||||
silencer: newSilencer,
|
|
||||||
trigger: newTrigger,
|
|
||||||
customName: newCustomName,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
|
|
||||||
);
|
|
||||||
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
|
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showAddMaintenanceDialog() async {
|
|
||||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
final descController = TextEditingController();
|
final descController = TextEditingController();
|
||||||
MaintenanceType selectedType = MaintenanceType.cleaning;
|
MaintenanceType selectedType = MaintenanceType.cleaning;
|
||||||
DateTime selectedDate = DateTime.now();
|
|
||||||
|
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -547,7 +357,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<MaintenanceType>(
|
DropdownButtonFormField<MaintenanceType>(
|
||||||
initialValue: selectedType,
|
value: selectedType,
|
||||||
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
|
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
|
||||||
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
onChanged: (v) => setState(() => selectedType = v!),
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
@@ -557,27 +367,6 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
InkWell(
|
|
||||||
onTap: () async {
|
|
||||||
final picked = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: selectedDate,
|
|
||||||
firstDate: DateTime(2000),
|
|
||||||
lastDate: DateTime.now(),
|
|
||||||
);
|
|
||||||
if (picked != null) {
|
|
||||||
setState(() => selectedDate = picked);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: InputDecorator(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Date',
|
|
||||||
suffixIcon: Icon(Icons.calendar_today, size: 18),
|
|
||||||
),
|
|
||||||
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -589,21 +378,18 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result == true && descController.text.isNotEmpty) {
|
if (result == true && descController.text.isNotEmpty) {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.addMaintenanceEntry(
|
await repository.addMaintenanceEntry(
|
||||||
weaponId: _weapon.id,
|
weaponId: _weapon.id,
|
||||||
type: selectedType,
|
type: selectedType,
|
||||||
description: descController.text,
|
description: descController.text,
|
||||||
roundsSinceLast: _totalRounds,
|
roundsSinceLast: _totalRounds,
|
||||||
date: selectedDate,
|
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
|
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async {
|
||||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -620,8 +406,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.deleteMaintenanceEntry(entry.id);
|
await repository.deleteMaintenanceEntry(entry.id);
|
||||||
if (!mounted) return;
|
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/glass_container.dart';
|
|
||||||
import '../../core/widgets/weapon_type_icon.dart';
|
|
||||||
import '../../data/models/weapon.dart';
|
import '../../data/models/weapon.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
import 'weapon_detail_screen.dart';
|
import 'weapon_detail_screen.dart';
|
||||||
|
|
||||||
class WeaponListScreen extends StatefulWidget {
|
class WeaponListScreen extends StatefulWidget {
|
||||||
final int refreshTick;
|
const WeaponListScreen({super.key});
|
||||||
|
|
||||||
const WeaponListScreen({super.key, this.refreshTick = 0});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<WeaponListScreen> createState() => _WeaponListScreenState();
|
State<WeaponListScreen> createState() => _WeaponListScreenState();
|
||||||
@@ -27,14 +23,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
_loadWeapons();
|
_loadWeapons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(WeaponListScreen oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.refreshTick != widget.refreshTick) {
|
|
||||||
_loadWeapons();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadWeapons() async {
|
Future<void> _loadWeapons() async {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final weapons = await repository.getWeapons();
|
final weapons = await repository.getWeapons();
|
||||||
@@ -48,288 +36,81 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBody: true,
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Mon Armurerie'),
|
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
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _weapons.isEmpty
|
: _weapons.isEmpty
|
||||||
? _buildEmptyState(isDark)
|
? _buildEmptyState()
|
||||||
: _buildWeaponList(isDark),
|
: _buildWeaponList(),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showAddWeaponDialog(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(bool isDark) {
|
Widget _buildEmptyState() {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(32.0),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Icon(Icons.shield, size: 64, color: Colors.grey[400]),
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
GlassContainer(
|
const Text('Aucune arme enregistrée'),
|
||||||
borderRadius: 30,
|
const SizedBox(height: 24),
|
||||||
padding: const EdgeInsets.all(28),
|
ElevatedButton(
|
||||||
child: Icon(
|
onPressed: () => _showAddWeaponDialog(context),
|
||||||
Icons.shield_outlined,
|
child: const Text('Ajouter ma première arme'),
|
||||||
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) {
|
Widget _buildWeaponList() {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
AppConstants.defaultPadding,
|
|
||||||
12,
|
|
||||||
AppConstants.defaultPadding,
|
|
||||||
100, // Espace pour le floating dock
|
|
||||||
),
|
|
||||||
itemCount: _weapons.length,
|
itemCount: _weapons.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final weapon = _weapons[index];
|
final weapon = _weapons[index];
|
||||||
final accessories = _accessoryChips(weapon, isDark);
|
return Card(
|
||||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: ListTile(
|
||||||
return GlassContainer(
|
leading: CircleAvatar(
|
||||||
borderRadius: 18,
|
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
blur: 14,
|
child: Icon(
|
||||||
glowColor: primaryColor,
|
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
|
||||||
borderColor: isDark
|
color: AppTheme.primaryColor,
|
||||||
? 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();
|
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
},
|
subtitle: Text('${weapon.type.displayName} • ${weapon.caliber}'),
|
||||||
onLongPress: () => _confirmDelete(weapon),
|
trailing: Column(
|
||||||
child: Column(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
|
onTap: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)),
|
||||||
|
);
|
||||||
|
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
||||||
|
},
|
||||||
|
onLongPress: () => _confirmDelete(context, weapon),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _accessoryChips(Weapon weapon, bool isDark) {
|
void _showAddWeaponDialog(BuildContext context) async {
|
||||||
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 nameController = TextEditingController();
|
||||||
final caliberController = TextEditingController();
|
final caliberController = TextEditingController();
|
||||||
final magCountController = TextEditingController(text: '2');
|
final magCountController = TextEditingController(text: '2');
|
||||||
@@ -338,7 +119,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
|
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogCtx) => StatefulBuilder(
|
builder: (context) => StatefulBuilder(
|
||||||
builder: (context, setState) => AlertDialog(
|
builder: (context, setState) => AlertDialog(
|
||||||
title: const Text('Ajouter une arme'),
|
title: const Text('Ajouter une arme'),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
@@ -347,40 +128,18 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
|
||||||
labelText: 'Nom de l\'arme',
|
|
||||||
hintText: 'ex: Glock 17 Gen 5',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
DropdownButtonFormField<WeaponType>(
|
DropdownButtonFormField<WeaponType>(
|
||||||
initialValue: selectedType,
|
value: selectedType,
|
||||||
decoration: const InputDecoration(labelText: 'Type'),
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
items: WeaponType.values
|
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
.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!),
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: caliberController,
|
controller: caliberController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'),
|
||||||
labelText: 'Calibre',
|
|
||||||
hintText: 'ex: 9x19mm, .22 LR, .223 Rem',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -390,7 +149,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: magCapController,
|
controller: magCapController,
|
||||||
@@ -404,20 +163,15 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
onPressed: () => Navigator.pop(dialogCtx, false),
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(dialogCtx, true),
|
|
||||||
child: const Text('Ajouter'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result == true && nameController.text.isNotEmpty) {
|
if (result == true && nameController.text.isNotEmpty) {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.addWeapon(
|
await repository.addWeapon(
|
||||||
name: nameController.text,
|
name: nameController.text,
|
||||||
type: selectedType,
|
type: selectedType,
|
||||||
@@ -425,38 +179,29 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
|||||||
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
||||||
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
|
||||||
_loadWeapons();
|
_loadWeapons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmDelete(Weapon weapon) async {
|
void _confirmDelete(BuildContext context, Weapon weapon) async {
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Supprimer l\'arme'),
|
title: const Text('Supprimer'),
|
||||||
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
|
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
|
||||||
actions: [
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
style: ElevatedButton.styleFrom(
|
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.deleteWeapon(weapon.id);
|
await repository.deleteWeapon(weapon.id);
|
||||||
if (!mounted) return;
|
|
||||||
_loadWeapons();
|
_loadWeapons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/models/session.dart';
|
import '../../data/models/session.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
@@ -10,9 +11,7 @@ import 'widgets/session_list_item.dart';
|
|||||||
import 'widgets/history_chart.dart';
|
import 'widgets/history_chart.dart';
|
||||||
|
|
||||||
class HistoryScreen extends StatefulWidget {
|
class HistoryScreen extends StatefulWidget {
|
||||||
final int refreshTick;
|
const HistoryScreen({super.key});
|
||||||
|
|
||||||
const HistoryScreen({super.key, this.refreshTick = 0});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||||
@@ -22,6 +21,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
List<Session> _sessions = [];
|
List<Session> _sessions = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
TargetType? _filterType;
|
TargetType? _filterType;
|
||||||
|
|
||||||
|
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
|
||||||
DateTimeRange? _selectedDateRange;
|
DateTimeRange? _selectedDateRange;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -30,14 +31,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
_loadSessions();
|
_loadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(HistoryScreen oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.refreshTick != widget.refreshTick) {
|
|
||||||
_loadSessions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadSessions() async {
|
Future<void> _loadSessions() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
@@ -50,14 +43,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_sessions = sessions;
|
_sessions = sessions;
|
||||||
|
|
||||||
if (_filterType != null) {
|
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
|
||||||
_sessions = _sessions
|
|
||||||
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_selectedDateRange != null) {
|
if (_selectedDateRange != null) {
|
||||||
_sessions = _sessions.where((s) {
|
_sessions = _sessions.where((s) {
|
||||||
|
// On compare uniquement les dates (sans les heures) pour éviter les bugs
|
||||||
final sessionDate = DateTime(
|
final sessionDate = DateTime(
|
||||||
s.createdAt.year,
|
s.createdAt.year,
|
||||||
s.createdAt.month,
|
s.createdAt.month,
|
||||||
@@ -96,13 +85,32 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MODIFICATION : Fonction DateRangePicker ---
|
||||||
Future<void> _pickDateRange() async {
|
Future<void> _pickDateRange() async {
|
||||||
final picked = await showDateRangePicker(
|
final DateTimeRange? picked = await showDateRangePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDateRange: _selectedDateRange,
|
initialDateRange: _selectedDateRange,
|
||||||
firstDate: DateTime(2020),
|
firstDate: DateTime(2020),
|
||||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||||
locale: const Locale('fr', 'FR'),
|
locale: const Locale('fr', 'FR'),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: ThemeData.light().copyWith(
|
||||||
|
colorScheme: ColorScheme.light(
|
||||||
|
primary: AppTheme.primaryColor, // En-tête et sélection
|
||||||
|
onPrimary: Colors.white, // Texte sur en-tête/sélection
|
||||||
|
surface: Colors.white, // Fond du calendrier
|
||||||
|
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
|
||||||
|
secondary: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
dialogBackgroundColor: Colors.white,
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
@@ -113,221 +121,122 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBody: true,
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Carnet de Tir'),
|
title: const Text('Historique'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
PopupMenuButton<TargetType?>(
|
||||||
icon: Icon(
|
icon: const Icon(Icons.filter_list),
|
||||||
Icons.date_range_outlined,
|
onSelected: (type) {
|
||||||
color: _selectedDateRange != null ? AppTheme.primaryColor : null,
|
setState(() => _filterType = type);
|
||||||
),
|
_loadSessions();
|
||||||
tooltip: 'Filtrer par date',
|
},
|
||||||
onPressed: _pickDateRange,
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(value: null, child: Text('Tous')),
|
||||||
|
...TargetType.values.map(
|
||||||
|
(type) =>
|
||||||
|
PopupMenuItem(value: type, child: Text(type.displayName)),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildFilterChips(isDark),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _sessions.isEmpty
|
: _sessions.isEmpty
|
||||||
? _buildEmptyState(isDark)
|
? _buildEmptyState()
|
||||||
: _buildContent(isDark),
|
: _buildContent(),
|
||||||
),
|
),
|
||||||
if (_selectedDateRange != null) _buildActivePeriodBanner(isDark),
|
_buildBottomFilterBar(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterChips(bool isDark) {
|
Widget _buildBottomFilterBar() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark ? AppTheme.darkBackground : AppTheme.lightBackground,
|
color: Theme.of(context).cardColor,
|
||||||
border: Border(
|
boxShadow: const [
|
||||||
bottom: BorderSide(
|
BoxShadow(
|
||||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
color: Colors.black26,
|
||||||
width: 1,
|
blurRadius: 4,
|
||||||
|
offset: Offset(0, -2),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
|
||||||
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(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.event, size: 18, color: AppTheme.primaryColor),
|
Expanded(
|
||||||
const SizedBox(width: 8),
|
child: OutlinedButton.icon(
|
||||||
Text(
|
onPressed: _pickDateRange,
|
||||||
'Période : $start - $end',
|
icon: const Icon(Icons.date_range, size: 18),
|
||||||
style: TextStyle(
|
label: Text(
|
||||||
fontSize: 13,
|
_selectedDateRange == null
|
||||||
fontWeight: FontWeight.w600,
|
? 'Choisir une période'
|
||||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
|
||||||
),
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_selectedDateRange != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: AppTheme.errorColor),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _selectedDateRange = null);
|
||||||
|
_loadSessions();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(bool isDark) {
|
Widget _buildEmptyState() {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(32.0),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
Container(
|
const Text('Aucune session sur cette période'),
|
||||||
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(bool isDark) {
|
Widget _buildContent() {
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _loadSessions,
|
onRefresh: _loadSessions,
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
if (_sessions.length >= 2 && _selectedDateRange == null)
|
if (_sessions.length >= 2 && _selectedDateRange == null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: HistoryChart(sessions: _sessions),
|
child: HistoryChart(sessions: _sessions),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
(context, index) {
|
final session = _sessions[index];
|
||||||
final session = _sessions[index];
|
return Padding(
|
||||||
return SessionListItem(
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: SessionListItem(
|
||||||
session: session,
|
session: session,
|
||||||
onTap: () => _openSessionDetail(session),
|
onTap: () => _openSessionDetail(session),
|
||||||
onDelete: () => _deleteSession(session),
|
onDelete: () => _deleteSession(session),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
childCount: _sessions.length,
|
}, childCount: _sessions.length),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -347,22 +256,21 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Supprimer la session'),
|
title: const Text('Supprimer'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Voulez-vous supprimer définitivement la session du ${DateFormat('dd/MM/yyyy à HH:mm').format(session.createdAt)} ?',
|
'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Annuler'),
|
child: const Text('Annuler'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
style: ElevatedButton.styleFrom(
|
child: const Text(
|
||||||
backgroundColor: AppTheme.errorColor,
|
'Supprimer',
|
||||||
foregroundColor: Colors.white,
|
style: TextStyle(color: AppTheme.errorColor),
|
||||||
),
|
),
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/glass_container.dart';
|
|
||||||
import '../../../data/models/session.dart';
|
import '../../../data/models/session.dart';
|
||||||
|
|
||||||
class SessionListItem extends StatelessWidget {
|
class SessionListItem extends StatelessWidget {
|
||||||
@@ -19,178 +18,124 @@ class SessionListItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
return Card(
|
||||||
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
clipBehavior: Clip.antiAlias,
|
||||||
final textMuted = isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted;
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
// Calcul de la couleur du score selon la moyenne par tir
|
child: Padding(
|
||||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
padding: const EdgeInsets.all(12),
|
||||||
final avg = session.averageScore;
|
child: Row(
|
||||||
Color scoreColor = primaryColor;
|
children: [
|
||||||
if (avg >= 9.0) {
|
// Thumbnail (from first target)
|
||||||
scoreColor = AppTheme.secondaryColor;
|
ClipRRect(
|
||||||
} else if (avg >= 7.5) {
|
borderRadius: BorderRadius.circular(8),
|
||||||
scoreColor = primaryColor;
|
child: SizedBox(
|
||||||
} else {
|
width: 60,
|
||||||
scoreColor = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
height: 60,
|
||||||
}
|
child: _buildThumbnail(),
|
||||||
|
|
||||||
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: Row(
|
|
||||||
children: [
|
|
||||||
// Aperçu de la cible avec bordure nette
|
|
||||||
ClipRRect(
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _buildThumbnail(isDark),
|
const SizedBox(width: 12),
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
|
|
||||||
// Informations de la session
|
// Info
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
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: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
children: [
|
||||||
decoration: BoxDecoration(
|
const Icon(
|
||||||
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
|
Icons.shield,
|
||||||
borderRadius: BorderRadius.circular(6),
|
size: 16,
|
||||||
),
|
color: AppTheme.primaryColor,
|
||||||
child: Text(
|
|
||||||
'${session.distance}m',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
session.weapon,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${session.targetCount} cible${session.targetCount > 1 ? 's' : ''} • ${session.totalShots} tirs',
|
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
||||||
style: TextStyle(
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
fontSize: 11,
|
),
|
||||||
color: textMuted,
|
const SizedBox(height: 4),
|
||||||
),
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${session.targetCount} cible(s) • ${session.distance}m',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 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: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: scoreColor,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Moy. ${session.averageScore.toStringAsFixed(1)}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: scoreColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Bouton supprimer
|
// Score
|
||||||
if (onDelete != null) ...[
|
Column(
|
||||||
const SizedBox(width: 4),
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
IconButton(
|
children: [
|
||||||
icon: const Icon(Icons.delete_outline_rounded),
|
Text(
|
||||||
onPressed: onDelete,
|
'${session.totalScore}',
|
||||||
color: textMuted,
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
iconSize: 20,
|
fontWeight: FontWeight.bold,
|
||||||
tooltip: 'Supprimer',
|
color: AppTheme.primaryColor,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
Text(
|
||||||
|
'${session.totalShots} tirs',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
if (onDelete != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: onDelete,
|
||||||
|
color: Colors.grey,
|
||||||
|
iconSize: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildThumbnail(bool isDark) {
|
Widget _buildThumbnail() {
|
||||||
if (session.analyses.isEmpty) return _buildPlaceholder(isDark);
|
if (session.analyses.isEmpty) return _buildPlaceholder();
|
||||||
|
|
||||||
final file = File(session.analyses.first.imagePath);
|
final file = File(session.analyses.first.imagePath);
|
||||||
|
|
||||||
if (file.existsSync()) {
|
if (file.existsSync()) {
|
||||||
return Image.file(
|
return Image.file(
|
||||||
file,
|
file,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, _, _) => _buildPlaceholder(isDark),
|
errorBuilder: (_, _, _) => _buildPlaceholder(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildPlaceholder(isDark);
|
return _buildPlaceholder();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaceholder(bool isDark) {
|
Widget _buildPlaceholder() {
|
||||||
return Icon(
|
return Container(
|
||||||
Icons.track_changes,
|
color: Colors.grey[200],
|
||||||
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
child: Icon(
|
||||||
size: 24,
|
Icons.track_changes,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
/// Widget carte réutilisable pour afficher une statistique.
|
||||||
import '../../../core/theme/app_theme.dart';
|
///
|
||||||
import '../../../core/widgets/glass_container.dart';
|
/// 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';
|
||||||
|
|
||||||
/// Widget carte télémétrique moderne avec effet Glassmorphism givré.
|
|
||||||
class StatsCard extends StatelessWidget {
|
class StatsCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String value;
|
final String value;
|
||||||
final Color color;
|
final Color color;
|
||||||
final String? subtitle;
|
|
||||||
|
|
||||||
const StatsCard({
|
const StatsCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -16,82 +19,33 @@ class StatsCard extends StatelessWidget {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.color,
|
required this.color,
|
||||||
this.subtitle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
return Card(
|
||||||
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
return GlassContainer(
|
child: Column(
|
||||||
borderRadius: 18,
|
children: [
|
||||||
blur: 14,
|
Icon(icon, color: color, size: 32),
|
||||||
glowColor: color,
|
const SizedBox(height: 8),
|
||||||
borderColor: isDark ? color.withValues(alpha: 0.22) : color.withValues(alpha: 0.18),
|
Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
value,
|
||||||
child: Column(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
fontWeight: FontWeight.bold,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
color: color,
|
||||||
children: [
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
const SizedBox(height: 4),
|
||||||
overflow: TextOverflow.ellipsis,
|
Text(
|
||||||
),
|
title,
|
||||||
const SizedBox(height: 2),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
Text(
|
color: Colors.grey[600],
|
||||||
title,
|
),
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: textSecondary,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../data/models/session.dart';
|
||||||
import '../../data/models/target_analysis.dart';
|
import '../../data/models/target_analysis.dart';
|
||||||
|
import '../../data/models/weapon.dart';
|
||||||
|
|
||||||
class SessionProvider extends ChangeNotifier {
|
class SessionProvider extends ChangeNotifier {
|
||||||
DateTime? _sessionDate;
|
DateTime? _sessionDate;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/models/weapon.dart';
|
import '../../data/models/weapon.dart';
|
||||||
@@ -25,17 +25,9 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
|||||||
Weapon? _selectedWeapon;
|
Weapon? _selectedWeapon;
|
||||||
bool _isLoadingWeapons = true;
|
bool _isLoadingWeapons = true;
|
||||||
|
|
||||||
|
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
|
||||||
DateTime _selectedDate = DateTime.now();
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -50,21 +42,8 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_availableWeapons = weapons;
|
_availableWeapons = weapons;
|
||||||
|
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
|
||||||
// Les armes rechargees sont de nouvelles instances (Weapon n'a pas
|
_selectedWeapon = _availableWeapons.first;
|
||||||
// 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!);
|
_updateSettingsForWeapon(_selectedWeapon!);
|
||||||
}
|
}
|
||||||
_isLoadingWeapons = false;
|
_isLoadingWeapons = false;
|
||||||
@@ -72,58 +51,25 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
void _updateSettingsForWeapon(Weapon weapon) {
|
||||||
_shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
|
_shotsPerTarget = weapon.magazineCapacity;
|
||||||
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
|
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
|
||||||
Future<void> _pickDate() async {
|
Future<void> _pickDate() async {
|
||||||
final DateTime? picked = await showDatePicker(
|
final DateTime? picked = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDate: _selectedDate,
|
initialDate: _selectedDate,
|
||||||
firstDate: DateTime(2020),
|
firstDate: DateTime(2020),
|
||||||
lastDate: DateTime(2100),
|
lastDate: DateTime(2100),
|
||||||
locale: const Locale('fr', 'FR'),
|
locale: const Locale('fr', 'FR'), // Force le calendrier en français
|
||||||
);
|
);
|
||||||
if (picked != null && picked != _selectedDate) {
|
if (picked != null && picked != _selectedDate) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
// On garde aussi l'heure actuelle lors du changement de date
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_selectedDate = DateTime(
|
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
|
||||||
picked.year,
|
|
||||||
picked.month,
|
|
||||||
picked.day,
|
|
||||||
now.hour,
|
|
||||||
now.minute,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +81,10 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
|||||||
|
|
||||||
final sessionId = repository.generateId();
|
final sessionId = repository.generateId();
|
||||||
|
|
||||||
|
// PASSE-PARTOUT : On envoie la session au provider.
|
||||||
|
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
|
||||||
|
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
|
||||||
|
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
|
||||||
sessionProvider.startSession(
|
sessionProvider.startSession(
|
||||||
_selectedWeapon!.displayName,
|
_selectedWeapon!.displayName,
|
||||||
_shotsPerTarget,
|
_shotsPerTarget,
|
||||||
@@ -153,380 +103,221 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
// Petit formatage sympa en français (ex: "27 mai 2026")
|
||||||
final formattedDate = DateFormat('dd MMMM yyyy • HH:mm', 'fr_FR').format(_selectedDate);
|
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Configuration de Session'),
|
title: const Text('Configuration de la session'),
|
||||||
),
|
),
|
||||||
body: _isLoadingWeapons
|
body: _isLoadingWeapons
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: SingleChildScrollView(
|
: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (_availableWeapons.isEmpty)
|
const Text(
|
||||||
_buildEmptyArmoryState(isDark)
|
'Informations générales',
|
||||||
else ...[
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
_buildWeaponAndDateSection(isDark, formattedDate),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildDistanceSection(isDark),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildShotsSection(isDark),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
_buildStartButton(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyArmoryState(bool isDark) {
|
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
|
||||||
return Container(
|
if (_availableWeapons.isEmpty)
|
||||||
padding: const EdgeInsets.all(24),
|
Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.all(16),
|
||||||
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
color: AppTheme.errorColor.withValues(alpha: 0.1),
|
||||||
border: Border.all(
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
color: AppTheme.errorColor.withValues(alpha: 0.5),
|
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,
|
|
||||||
),
|
),
|
||||||
),
|
child: Column(
|
||||||
],
|
|
||||||
),
|
|
||||||
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: [
|
children: [
|
||||||
Icon(
|
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
|
||||||
Icons.add_circle_outline,
|
const SizedBox(height: 8),
|
||||||
size: 18,
|
const Text(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
'Ton armurerie est vide.',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 4),
|
||||||
Text(
|
const Text(
|
||||||
'Ajouter une nouvelle arme',
|
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
|
||||||
style: TextStyle(
|
textAlign: TextAlign.center,
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
color: Theme.of(context).colorScheme.primary,
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
|
||||||
|
).then((_) {
|
||||||
|
_loadWeapons();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('ALLER À MON ARMURERIE'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<Weapon>(
|
||||||
|
value: _selectedWeapon,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Sélectionner une arme',
|
||||||
|
prefixIcon: const Icon(Icons.shield),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
items: _availableWeapons.map((w) => DropdownMenuItem(
|
||||||
|
value: w,
|
||||||
|
child: Text(w.displayName),
|
||||||
|
)).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedWeapon = value;
|
||||||
|
_updateSettingsForWeapon(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// NOUVEAU CHAMP : Sélecteur de date interactif
|
||||||
|
InkWell(
|
||||||
|
onTap: _availableWeapons.isEmpty ? null : _pickDate,
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: TextFormField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Date de la session',
|
||||||
|
prefixIcon: const Icon(Icons.calendar_today),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
controller: TextEditingController(text: formattedDate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Distance selector
|
||||||
|
const Text(
|
||||||
|
'Distance de tir (mètres)',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _distance.toDouble(),
|
||||||
|
min: 5,
|
||||||
|
max: 300,
|
||||||
|
divisions: 59,
|
||||||
|
label: '${_distance}m',
|
||||||
|
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
||||||
|
setState(() {
|
||||||
|
_distance = value.round();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 70,
|
||||||
|
height: 50,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.secondaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${_distance}m',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.secondaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Shots per target
|
||||||
|
const Text(
|
||||||
|
'Nombre de balles pour la cible actuelle',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _shotsPerTarget.toDouble(),
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
divisions: 49,
|
||||||
|
label: '$_shotsPerTarget',
|
||||||
|
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
||||||
|
setState(() {
|
||||||
|
_shotsPerTarget = value.round();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$_shotsPerTarget',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
|
||||||
|
? null
|
||||||
|
: _startSession,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
icon: const Icon(Icons.play_arrow),
|
||||||
onChanged: (value) {
|
label: const Text(
|
||||||
if (value == null) return;
|
'DÉMARRER LA SESSION',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:file_selector/file_selector.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
import '../../core/theme/app_theme.dart';
|
|
||||||
import '../../core/widgets/metric_info_button.dart';
|
|
||||||
import '../../data/models/session.dart';
|
import '../../data/models/session.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
import '../../services/backup_service.dart';
|
|
||||||
import '../../services/statistics_service.dart';
|
import '../../services/statistics_service.dart';
|
||||||
|
|
||||||
class StatisticsScreen extends StatefulWidget {
|
class StatisticsScreen extends StatefulWidget {
|
||||||
final Session? singleSession;
|
final Session? singleSession;
|
||||||
|
|
||||||
/// Incrémenté par la navigation à chaque fois que l'onglet Stats est ouvert,
|
const StatisticsScreen({super.key, this.singleSession});
|
||||||
/// pour forcer un rechargement des données (l'écran est gardé vivant par un
|
|
||||||
/// IndexedStack, sinon les filtres arme/distance resteraient périmés).
|
|
||||||
final int refreshTick;
|
|
||||||
|
|
||||||
const StatisticsScreen({super.key, this.singleSession, this.refreshTick = 0});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatisticsScreen> createState() => _StatisticsScreenState();
|
State<StatisticsScreen> createState() => _StatisticsScreenState();
|
||||||
@@ -30,7 +18,7 @@ class StatisticsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _StatisticsScreenState extends State<StatisticsScreen> {
|
class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||||
final StatisticsService _statisticsService = StatisticsService();
|
final StatisticsService _statisticsService = StatisticsService();
|
||||||
final StatsPeriod _selectedPeriod = StatsPeriod.all;
|
StatsPeriod _selectedPeriod = StatsPeriod.all;
|
||||||
SessionStatistics? _statistics;
|
SessionStatistics? _statistics;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
List<Session> _allSessions = [];
|
List<Session> _allSessions = [];
|
||||||
@@ -41,34 +29,12 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
List<String> _availableWeapons = ['Toutes'];
|
List<String> _availableWeapons = ['Toutes'];
|
||||||
List<String> _availableDistances = ['Toutes'];
|
List<String> _availableDistances = ['Toutes'];
|
||||||
|
|
||||||
// --- Comparateur de sessions ---
|
|
||||||
// Quand 2 sessions sont sélectionnées, l'écran passe en mode comparaison :
|
|
||||||
// un switch permet d'alterner l'affichage des stats entre la session A et B.
|
|
||||||
Session? _compareA;
|
|
||||||
Session? _compareB;
|
|
||||||
bool _showingB = false; // false = on affiche A, true = on affiche B
|
|
||||||
bool get _compareMode => _compareA != null && _compareB != null;
|
|
||||||
|
|
||||||
// --- Sauvegarde (export/import JSON) ---
|
|
||||||
bool _isBackupBusy = false;
|
|
||||||
final GlobalKey _exportButtonKey = GlobalKey();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(StatisticsScreen oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
// L'onglet vient d'être ré-ouvert : on recharge pour rafraîchir les listes
|
|
||||||
// de filtres (armes/distances) et les stats avec les nouvelles sessions.
|
|
||||||
if (oldWidget.refreshTick != widget.refreshTick) {
|
|
||||||
_loadStatistics();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadStatistics() async {
|
Future<void> _loadStatistics() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
@@ -113,17 +79,6 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _calculateStats() {
|
void _calculateStats() {
|
||||||
// Mode comparaison : on calcule les stats sur la seule session active
|
|
||||||
// (A ou B selon le switch), sans filtre de période.
|
|
||||||
if (_compareMode) {
|
|
||||||
final active = _showingB ? _compareB! : _compareA!;
|
|
||||||
_statistics = _statisticsService.calculateStatistics(
|
|
||||||
[active],
|
|
||||||
period: StatsPeriod.all,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrer les sessions avant calcul
|
// Filtrer les sessions avant calcul
|
||||||
final filteredSessions = _allSessions.where((s) {
|
final filteredSessions = _allSessions.where((s) {
|
||||||
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
|
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
|
||||||
@@ -137,80 +92,6 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _sessionLabel(Session s) {
|
|
||||||
final d = s.createdAt;
|
|
||||||
final date = '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
|
|
||||||
return '${s.weapon} • $date • ${s.totalScore} pts';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sélection des 2 sessions à comparer via un dialog à deux listes déroulantes.
|
|
||||||
Future<void> _openCompareDialog() async {
|
|
||||||
if (_allSessions.length < 2) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Il faut au moins 2 sessions enregistrées pour comparer.')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Session? a = _compareA ?? _allSessions[0];
|
|
||||||
Session? b = _compareB ?? _allSessions[1];
|
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => StatefulBuilder(
|
|
||||||
builder: (context, setState) => AlertDialog(
|
|
||||||
title: const Text('Comparer 2 sessions'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
DropdownButtonFormField<Session>(
|
|
||||||
initialValue: a,
|
|
||||||
isExpanded: true,
|
|
||||||
decoration: const InputDecoration(labelText: 'Session A'),
|
|
||||||
items: _allSessions
|
|
||||||
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) => setState(() => a = v),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
DropdownButtonFormField<Session>(
|
|
||||||
initialValue: b,
|
|
||||||
isExpanded: true,
|
|
||||||
decoration: const InputDecoration(labelText: 'Session B'),
|
|
||||||
items: _allSessions
|
|
||||||
.map((s) => DropdownMenuItem(value: s, child: Text(_sessionLabel(s), overflow: TextOverflow.ellipsis)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) => setState(() => b = v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
|
||||||
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Comparer')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true && a != null && b != null) {
|
|
||||||
setState(() {
|
|
||||||
_compareA = a;
|
|
||||||
_compareB = b;
|
|
||||||
_showingB = false;
|
|
||||||
_calculateStats();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _exitCompareMode() {
|
|
||||||
setState(() {
|
|
||||||
_compareA = null;
|
|
||||||
_compareB = null;
|
|
||||||
_showingB = false;
|
|
||||||
_calculateStats();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
List<double> _getScoreHistory() {
|
List<double> _getScoreHistory() {
|
||||||
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
|
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
|
||||||
// Sort sessions by date and take last 10
|
// Sort sessions by date and take last 10
|
||||||
@@ -236,71 +117,53 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBody: true,
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Statistiques'),
|
title: const Text('Statistiques'),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: Column(
|
: RefreshIndicator(
|
||||||
children: [
|
onRefresh: _loadStatistics,
|
||||||
// HEADER COLLANT : filtres + comparateur toujours visibles
|
child: SingleChildScrollView(
|
||||||
Material(
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
elevation: 2,
|
padding: const EdgeInsets.all(16),
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
child: Column(
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
// 1. FILTRES (Arme et Distance)
|
||||||
child: Column(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// 1. FILTRES (Arme et Distance)
|
Expanded(
|
||||||
Row(
|
child: _buildDropdown(
|
||||||
children: [
|
'Arme utilisée',
|
||||||
Expanded(
|
_selectedWeapon,
|
||||||
child: _buildDropdown(
|
_availableWeapons,
|
||||||
'Arme utilisée',
|
(val) {
|
||||||
_selectedWeapon,
|
setState(() {
|
||||||
_availableWeapons,
|
_selectedWeapon = val!;
|
||||||
(val) {
|
_calculateStats();
|
||||||
setState(() {
|
});
|
||||||
_selectedWeapon = val!;
|
},
|
||||||
_calculateStats();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildDropdown(
|
|
||||||
'Distance',
|
|
||||||
_selectedDistance,
|
|
||||||
_availableDistances,
|
|
||||||
(val) {
|
|
||||||
setState(() {
|
|
||||||
_selectedDistance = val!;
|
|
||||||
_calculateStats();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
// 1bis. COMPARATEUR DE SESSIONS
|
Expanded(
|
||||||
_buildComparator(),
|
child: _buildDropdown(
|
||||||
],
|
'Distance',
|
||||||
),
|
_selectedDistance,
|
||||||
|
_availableDistances,
|
||||||
|
(val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDistance = val!;
|
||||||
|
_calculateStats();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
Expanded(
|
|
||||||
child: RefreshIndicator(
|
|
||||||
onRefresh: _loadStatistics,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -328,46 +191,18 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
'Score',
|
'Score',
|
||||||
'${_statistics?.totalScore ?? 0}',
|
'${_statistics?.totalScore ?? 0}',
|
||||||
_getScoreHistory(),
|
_getScoreHistory(),
|
||||||
explanations: const [
|
|
||||||
MetricExplanation(
|
|
||||||
'Score',
|
|
||||||
'Total des points marqués sur la période sélectionnée.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildChartSection(
|
_buildChartSection(
|
||||||
'Précision',
|
'Précision',
|
||||||
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
||||||
_getPrecisionHistory(),
|
_getPrecisionHistory(),
|
||||||
explanations: const [
|
|
||||||
MetricExplanation(
|
|
||||||
'Précision',
|
|
||||||
'Proximité moyenne de vos impacts par rapport au centre '
|
|
||||||
'de la cible (calculée sur les distances).',
|
|
||||||
),
|
|
||||||
MetricExplanation(
|
|
||||||
'À ne pas confondre',
|
|
||||||
'C\'est différent de la « Réussite » affichée dans une '
|
|
||||||
'session, qui se base sur les points marqués. Les deux '
|
|
||||||
'mesurent des choses distinctes, leurs pourcentages '
|
|
||||||
'ne sont donc pas identiques.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildChartSection(
|
_buildChartSection(
|
||||||
'Étalement moyen',
|
'Groupement moyen',
|
||||||
'${((_statistics?.precision.groupingDiameter ?? 0) * 100).toStringAsFixed(1)}%',
|
'${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%',
|
||||||
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
|
_getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
|
||||||
explanations: const [
|
|
||||||
MetricExplanation(
|
|
||||||
'Étalement moyen',
|
|
||||||
'Étalement moyen de vos groupements : distance entre les '
|
|
||||||
'impacts les plus éloignés, en % de la largeur de '
|
|
||||||
'l\'image. Plus c\'est bas, plus vos tirs sont serrés.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 25),
|
const SizedBox(height: 25),
|
||||||
@@ -385,158 +220,41 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
_buildBiasWarning(),
|
_buildBiasWarning(),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 25),
|
|
||||||
|
|
||||||
// 5. SAUVEGARDE : export/import de toutes les données
|
|
||||||
_buildBackupSection(),
|
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comparateur : bouton pour choisir 2 sessions, puis switch pour alterner.
|
|
||||||
Widget _buildComparator() {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
if (!_compareMode) {
|
|
||||||
return SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: _openCompareDialog,
|
|
||||||
icon: const Icon(Icons.compare_arrows),
|
|
||||||
label: const Text('Comparer 2 sessions'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
final activeColor = Theme.of(context).colorScheme.primary;
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: activeColor.withValues(alpha: isDark ? 0.12 : 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(color: activeColor.withValues(alpha: 0.35)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.compare_arrows, color: activeColor, size: 18),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'Comparaison de sessions',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 13,
|
|
||||||
color: activeColor,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Quitter la comparaison',
|
|
||||||
icon: const Icon(Icons.close, size: 18),
|
|
||||||
onPressed: _exitCompareMode,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Switch pour alterner entre les 2 sessions.
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'A · ${_sessionLabel(_compareA!)}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: _showingB ? theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5) : null,
|
|
||||||
fontWeight: _showingB ? FontWeight.normal : FontWeight.bold,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
value: _showingB,
|
|
||||||
activeThumbColor: activeColor,
|
|
||||||
onChanged: (v) => setState(() {
|
|
||||||
_showingB = v;
|
|
||||||
_calculateStats();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'B · ${_sessionLabel(_compareB!)}',
|
|
||||||
textAlign: TextAlign.right,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: _showingB ? null : theme.textTheme.bodySmall?.color?.withValues(alpha: 0.5),
|
|
||||||
fontWeight: _showingB ? FontWeight.bold : FontWeight.normal,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Affichage : session ${_showingB ? 'B' : 'A'}',
|
|
||||||
style: TextStyle(fontSize: 11, color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDropdown(
|
Widget _buildDropdown(
|
||||||
String label,
|
String label,
|
||||||
String value,
|
String value,
|
||||||
List<String> items,
|
List<String> items,
|
||||||
void Function(String?) onChanged,
|
void Function(String?) onChanged,
|
||||||
) {
|
) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final theme = Theme.of(context);
|
||||||
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(color: theme.dividerColor),
|
||||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10),
|
||||||
color: textSecondary,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownButton<String>(
|
DropdownButton<String>(
|
||||||
value: value,
|
value: value,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
underline: const SizedBox(),
|
underline: Container(),
|
||||||
dropdownColor: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
|
dropdownColor: theme.colorScheme.surfaceContainerHighest,
|
||||||
style: TextStyle(
|
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14),
|
||||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
items: items
|
items: items
|
||||||
.map(
|
.map(
|
||||||
(String val) =>
|
(String val) =>
|
||||||
@@ -552,45 +270,28 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
|
|
||||||
// Widget pour les petites cartes de stats
|
// Widget pour les petites cartes de stats
|
||||||
Widget _buildQuickStat(String label, String value, IconData icon) {
|
Widget _buildQuickStat(String label, String value, IconData icon) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final theme = Theme.of(context);
|
||||||
final primary = Theme.of(context).colorScheme.primary;
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
|
||||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Icon(icon, color: const Color(0xFF1A73E8), size: 20),
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: primary.withValues(alpha: isDark ? 0.15 : 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Icon(icon, color: primary, size: 20),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
color: theme.textTheme.titleLarge?.color,
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12),
|
||||||
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -601,48 +302,31 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
Widget _buildChartSection(
|
Widget _buildChartSection(
|
||||||
String title,
|
String title,
|
||||||
String value,
|
String value,
|
||||||
List<double> dataPoints, {
|
List<double> dataPoints,
|
||||||
List<MetricExplanation>? explanations,
|
) {
|
||||||
}) {
|
final theme = Theme.of(context);
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
|
||||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text(
|
||||||
children: [
|
title,
|
||||||
Text(
|
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
|
||||||
title,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (explanations != null) ...[
|
|
||||||
const Spacer(),
|
|
||||||
MetricInfoButton(title: title, explanations: explanations),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
color: theme.textTheme.headlineMedium?.color,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
const SizedBox(width: 20),
|
||||||
@@ -944,263 +628,6 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SAUVEGARDE : EXPORT / IMPORT JSON ---
|
|
||||||
|
|
||||||
Widget _buildBackupSection() {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.save_alt, color: theme.textTheme.titleMedium?.color, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Sauvegarde',
|
|
||||||
style: TextStyle(
|
|
||||||
color: theme.textTheme.titleMedium?.color,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Exporte toutes tes sessions, tes stats et ton armurerie dans un '
|
|
||||||
'fichier JSON, à envoyer où tu veux. L\'import fusionne le fichier '
|
|
||||||
'avec tes données actuelles (rien n\'est effacé).',
|
|
||||||
style: TextStyle(
|
|
||||||
color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
key: _exportButtonKey,
|
|
||||||
onPressed: _isBackupBusy ? null : _exportBackup,
|
|
||||||
icon: const Icon(Icons.ios_share, size: 18),
|
|
||||||
label: const Text('Exporter'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: _isBackupBusy ? null : _importBackup,
|
|
||||||
icon: const Icon(Icons.file_download_outlined, size: 18),
|
|
||||||
label: const Text('Importer'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (_isBackupBusy) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const LinearProgressIndicator(minHeight: 2),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupService _backupService() =>
|
|
||||||
BackupService(repository: context.read<SessionRepository>());
|
|
||||||
|
|
||||||
Future<void> _exportBackup() async {
|
|
||||||
final includeImages = await _askIncludeImages();
|
|
||||||
if (includeImages == null || !mounted) return;
|
|
||||||
|
|
||||||
setState(() => _isBackupBusy = true);
|
|
||||||
try {
|
|
||||||
final file = await _backupService().exportToFile(
|
|
||||||
includeImages: includeImages,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
// sharePositionOrigin : obligatoire pour l'iPad, ignoré ailleurs.
|
|
||||||
final box = _exportButtonKey.currentContext?.findRenderObject() as RenderBox?;
|
|
||||||
final origin = box != null && box.hasSize
|
|
||||||
? box.localToGlobal(Offset.zero) & box.size
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await SharePlus.instance.share(
|
|
||||||
ShareParams(
|
|
||||||
files: [XFile(file.path, mimeType: 'application/json')],
|
|
||||||
fileNameOverrides: [p.basename(file.path)],
|
|
||||||
subject: 'Sauvegarde IMPACT',
|
|
||||||
text: 'Sauvegarde de mes sessions de tir (${p.basename(file.path)})',
|
|
||||||
sharePositionOrigin: origin,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_showMessage('Export impossible : $e', isError: true);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isBackupBusy = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Les photos de cibles alourdissent énormément le fichier : on laisse le
|
|
||||||
/// choix entre une sauvegarde légère (données seules) et une sauvegarde
|
|
||||||
/// complète (photos encodées dans le JSON).
|
|
||||||
Future<bool?> _askIncludeImages() {
|
|
||||||
var includeImages = false;
|
|
||||||
return showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => StatefulBuilder(
|
|
||||||
builder: (context, setDialogState) => AlertDialog(
|
|
||||||
title: const Text('Exporter mes données'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Le fichier contiendra toutes tes sessions (cibles, impacts, '
|
|
||||||
'scores), tes statistiques et ton armurerie.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
value: includeImages,
|
|
||||||
onChanged: (v) => setDialogState(() => includeImages = v),
|
|
||||||
title: const Text('Inclure les photos des cibles'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Sauvegarde complète, mais fichier beaucoup plus lourd.',
|
|
||||||
style: TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(context, includeImages),
|
|
||||||
child: const Text('Exporter'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _importBackup() async {
|
|
||||||
const jsonGroup = XTypeGroup(
|
|
||||||
label: 'Sauvegarde IMPACT (.json)',
|
|
||||||
extensions: ['json'],
|
|
||||||
// Android renvoie parfois un type générique pour un .json : on accepte
|
|
||||||
// large, le contenu est validé à la lecture de toute façon.
|
|
||||||
mimeTypes: ['application/json', 'text/plain', 'application/octet-stream'],
|
|
||||||
uniformTypeIdentifiers: ['public.json', 'public.text'],
|
|
||||||
);
|
|
||||||
|
|
||||||
final picked = await openFile(acceptedTypeGroups: const [jsonGroup]);
|
|
||||||
if (picked == null || !mounted) return;
|
|
||||||
|
|
||||||
setState(() => _isBackupBusy = true);
|
|
||||||
try {
|
|
||||||
final service = _backupService();
|
|
||||||
final preview = await service.readBackup(File(picked.path));
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final confirmed = await _confirmImport(preview);
|
|
||||||
if (confirmed != true || !mounted) return;
|
|
||||||
|
|
||||||
final result = await service.applyBackup(preview);
|
|
||||||
await _loadStatistics();
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final details = [
|
|
||||||
'${result.sessions} session(s)',
|
|
||||||
'${result.weapons} arme(s)',
|
|
||||||
if (result.maintenance > 0) '${result.maintenance} entretien(s)',
|
|
||||||
if (result.images > 0) '${result.images} photo(s)',
|
|
||||||
].join(' · ');
|
|
||||||
_showMessage(
|
|
||||||
result.errors.isEmpty
|
|
||||||
? 'Import terminé : $details'
|
|
||||||
: 'Import terminé : $details — ${result.errors.length} entrée(s) ignorée(s)',
|
|
||||||
);
|
|
||||||
} on BackupFormatException catch (e) {
|
|
||||||
_showMessage(e.message, isError: true);
|
|
||||||
} catch (e) {
|
|
||||||
_showMessage('Import impossible : $e', isError: true);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isBackupBusy = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool?> _confirmImport(BackupPreview preview) {
|
|
||||||
final date = preview.exportedAt;
|
|
||||||
final dateLabel = date == null
|
|
||||||
? null
|
|
||||||
: '${date.day.toString().padLeft(2, '0')}/'
|
|
||||||
'${date.month.toString().padLeft(2, '0')}/${date.year}';
|
|
||||||
|
|
||||||
return showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Importer cette sauvegarde ?'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (dateLabel != null) Text('Exportée le $dateLabel'),
|
|
||||||
if (dateLabel != null) const SizedBox(height: 8),
|
|
||||||
Text('• ${preview.sessionCount} session(s)'),
|
|
||||||
Text('• ${preview.targetCount} cible(s), ${preview.shotCount} impact(s)'),
|
|
||||||
Text('• ${preview.weaponCount} arme(s), ${preview.maintenanceCount} entretien(s)'),
|
|
||||||
Text(preview.hasImages
|
|
||||||
? '• Photos des cibles incluses'
|
|
||||||
: '• Sans photos de cibles'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text(
|
|
||||||
'Les données actuelles sont conservées. Une session déjà '
|
|
||||||
'présente est simplement mise à jour.',
|
|
||||||
style: TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: const Text('Importer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showMessage(String message, {bool isError = false}) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(message),
|
|
||||||
backgroundColor: isError ? Colors.red.shade700 : null,
|
|
||||||
duration: const Duration(seconds: 4),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HeatMapPainter extends CustomPainter {
|
class _HeatMapPainter extends CustomPainter {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'core/theme/theme_provider.dart';
|
import 'core/theme/theme_provider.dart';
|
||||||
import 'data/repositories/session_repository.dart';
|
import 'data/repositories/session_repository.dart';
|
||||||
|
import 'services/target_detection_service.dart';
|
||||||
import 'services/score_calculator_service.dart';
|
import 'services/score_calculator_service.dart';
|
||||||
import 'services/grouping_analyzer_service.dart';
|
import 'services/grouping_analyzer_service.dart';
|
||||||
|
import 'services/image_processing_service.dart';
|
||||||
import 'features/session/session_provider.dart';
|
import 'features/session/session_provider.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
@@ -30,6 +32,14 @@ void main() async {
|
|||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
|
Provider<ImageProcessingService>(
|
||||||
|
create: (_) => ImageProcessingService(),
|
||||||
|
),
|
||||||
|
Provider<TargetDetectionService>(
|
||||||
|
create: (context) => TargetDetectionService(
|
||||||
|
imageProcessingService: context.read<ImageProcessingService>(),
|
||||||
|
),
|
||||||
|
),
|
||||||
Provider<ScoreCalculatorService>(
|
Provider<ScoreCalculatorService>(
|
||||||
create: (_) => ScoreCalculatorService(),
|
create: (_) => ScoreCalculatorService(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:ui';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
import 'features/history/history_screen.dart';
|
import 'features/history/history_screen.dart';
|
||||||
@@ -6,22 +5,6 @@ import 'features/statistics/statistics_screen.dart';
|
|||||||
import 'features/garage/weapon_list_screen.dart';
|
import 'features/garage/weapon_list_screen.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
|
|
||||||
/// Index des onglets de la barre de navigation principale.
|
|
||||||
const int mainTabHome = 0;
|
|
||||||
const int mainTabHistory = 1;
|
|
||||||
const int mainTabStats = 2;
|
|
||||||
const int mainTabGarage = 3;
|
|
||||||
|
|
||||||
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel écran.
|
|
||||||
final GlobalKey<State<MainNavigationHolder>> mainNavKey =
|
|
||||||
GlobalKey<State<MainNavigationHolder>>();
|
|
||||||
|
|
||||||
/// Ouvre l'onglet [index] de la navigation principale.
|
|
||||||
void openMainTab(int index) {
|
|
||||||
final state = mainNavKey.currentState;
|
|
||||||
if (state is _MainNavigationHolderState) state.selectTab(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
class MainNavigationHolder extends StatefulWidget {
|
class MainNavigationHolder extends StatefulWidget {
|
||||||
const MainNavigationHolder({super.key});
|
const MainNavigationHolder({super.key});
|
||||||
|
|
||||||
@@ -32,161 +15,66 @@ class MainNavigationHolder extends StatefulWidget {
|
|||||||
class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
int _statsTick = 0;
|
final List<Widget> _screens = [
|
||||||
int _historyTick = 0;
|
const HomeScreen(),
|
||||||
int _homeTick = 0;
|
const HistoryScreen(),
|
||||||
int _garageTick = 0;
|
const StatisticsScreen(),
|
||||||
|
const WeaponListScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
void selectTab(int index) {
|
void _onItemTapped(int index) {
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedIndex = index;
|
_selectedIndex = index;
|
||||||
if (index == mainTabHome) _homeTick++;
|
|
||||||
if (index == mainTabHistory) _historyTick++;
|
|
||||||
if (index == mainTabStats) _statsTick++;
|
|
||||||
if (index == mainTabGarage) _garageTick++;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
|
|
||||||
final screens = [
|
|
||||||
HomeScreen(refreshTick: _homeTick),
|
|
||||||
HistoryScreen(refreshTick: _historyTick),
|
|
||||||
StatisticsScreen(refreshTick: _statsTick),
|
|
||||||
WeaponListScreen(refreshTick: _garageTick),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBody: true,
|
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: _selectedIndex,
|
index: _selectedIndex,
|
||||||
children: screens,
|
children: _screens,
|
||||||
),
|
),
|
||||||
bottomNavigationBar: _buildFloatingGlassDock(isDark),
|
bottomNavigationBar: Container(
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
blurRadius: 24,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, -2),
|
||||||
),
|
),
|
||||||
if (isDark)
|
|
||||||
BoxShadow(
|
|
||||||
color: primaryColor.withValues(alpha: 0.12),
|
|
||||||
blurRadius: 16,
|
|
||||||
spreadRadius: -4,
|
|
||||||
offset: const Offset(0, -2),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: BottomNavigationBar(
|
||||||
borderRadius: BorderRadius.circular(24),
|
currentIndex: _selectedIndex,
|
||||||
child: BackdropFilter(
|
onTap: _onItemTapped,
|
||||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
type: BottomNavigationBarType.fixed,
|
||||||
child: Container(
|
backgroundColor: Theme.of(context).cardColor,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
selectedItemColor: AppTheme.primaryColor,
|
||||||
decoration: BoxDecoration(
|
unselectedItemColor: Colors.grey,
|
||||||
color: isDark
|
showUnselectedLabels: true,
|
||||||
? const Color(0xFF101722).withValues(alpha: 0.78)
|
items: const [
|
||||||
: Colors.white.withValues(alpha: 0.85),
|
BottomNavigationBarItem(
|
||||||
borderRadius: BorderRadius.circular(24),
|
icon: Icon(Icons.home_outlined),
|
||||||
border: Border.all(
|
activeIcon: Icon(Icons.home),
|
||||||
color: isDark
|
label: 'Accueil',
|
||||||
? 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.history_outlined),
|
||||||
|
activeIcon: Icon(Icons.history),
|
||||||
|
label: 'Historique',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.analytics_outlined),
|
||||||
|
activeIcon: Icon(Icons.analytics),
|
||||||
|
label: 'Stats',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.shield_outlined),
|
||||||
|
activeIcon: Icon(Icons.shield),
|
||||||
|
label: 'Armurerie',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,62 +8,16 @@ import '../data/models/shot.dart';
|
|||||||
import '../data/models/target_type.dart';
|
import '../data/models/target_type.dart';
|
||||||
import 'wallet_identity_service.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 {
|
class AiExportService {
|
||||||
|
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
|
||||||
|
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
|
||||||
|
static String get _defaultApiUrl {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return 'http://10.0.2.2:3000/api/upload';
|
||||||
|
}
|
||||||
|
return 'http://localhost:3000/api/upload';
|
||||||
|
}
|
||||||
|
|
||||||
/// Extrait les informations de l'appareil
|
/// Extrait les informations de l'appareil
|
||||||
Future<Map<String, dynamic>> _getDeviceInfo() async {
|
Future<Map<String, dynamic>> _getDeviceInfo() async {
|
||||||
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||||
@@ -91,7 +45,7 @@ class AiExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Exporte l'image et les données de plotting vers le serveur
|
/// Exporte l'image et les données de plotting vers le serveur
|
||||||
Future<AiExportResult> exportData({
|
Future<bool> exportData({
|
||||||
required String imagePath,
|
required String imagePath,
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
required TargetType targetType,
|
required TargetType targetType,
|
||||||
@@ -99,30 +53,26 @@ class AiExportService {
|
|||||||
required double targetCenterY,
|
required double targetCenterY,
|
||||||
required double targetRadius,
|
required double targetRadius,
|
||||||
required List<Shot> shots,
|
required List<Shot> shots,
|
||||||
int distanceMeters = 25,
|
|
||||||
String weaponName = 'Unknown',
|
|
||||||
String? apiUrl,
|
String? apiUrl,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final walletService = WalletIdentityService();
|
final url = Uri.parse(apiUrl ?? _defaultApiUrl);
|
||||||
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);
|
final request = http.MultipartRequest('POST', url);
|
||||||
request.headers['X-API-KEY'] = WalletIdentityService.apiKey;
|
|
||||||
|
|
||||||
// 1. Prepare image
|
// 1. Prepare image
|
||||||
final file = File(imagePath);
|
final file = File(imagePath);
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
return AiExportResult.error(
|
throw Exception('Le fichier image n\'existe pas');
|
||||||
code: 'FILE_NOT_FOUND',
|
|
||||||
message: 'Le fichier image cible est introuvable.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read image metadata (approximate dimensions since decoding image can be heavy)
|
||||||
|
// On the frontend we usually have aspectRatio, here we use generic values if not available.
|
||||||
final deviceData = await _getDeviceInfo();
|
final deviceData = await _getDeviceInfo();
|
||||||
|
|
||||||
|
// We approximate the target corners from center and radius
|
||||||
|
// radius is relative (0 to 1). We need image width/height to get pixels.
|
||||||
|
// But we can just pass relative corners as well, or a normalized bounding box.
|
||||||
|
// Let's create normalized corners (0 to 1).
|
||||||
final corners = [
|
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},
|
||||||
@@ -146,6 +96,7 @@ class AiExportService {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Get and hash the wallet identity
|
// Get and hash the wallet identity
|
||||||
|
final walletService = WalletIdentityService();
|
||||||
final phrase = await walletService.getIdentityPhrase();
|
final phrase = await walletService.getIdentityPhrase();
|
||||||
final phraseBytes = utf8.encode(phrase);
|
final phraseBytes = utf8.encode(phrase);
|
||||||
final walletHash = sha256.convert(phraseBytes).toString();
|
final walletHash = sha256.convert(phraseBytes).toString();
|
||||||
@@ -158,8 +109,9 @@ class AiExportService {
|
|||||||
"device_info": deviceData,
|
"device_info": deviceData,
|
||||||
"target_metadata": {
|
"target_metadata": {
|
||||||
"type": targetType.name,
|
"type": targetType.name,
|
||||||
"distance_meters": distanceMeters,
|
"distance_meters": 25, // Default/placeholder
|
||||||
"weapon": weaponName,
|
"weapon": "Unknown", // Default/placeholder
|
||||||
|
// The backend could extract exact width/height from the image.
|
||||||
},
|
},
|
||||||
"plotting": {
|
"plotting": {
|
||||||
"target_corners": corners,
|
"target_corners": corners,
|
||||||
@@ -167,61 +119,29 @@ class AiExportService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add fields to request
|
||||||
request.fields['plotting'] = jsonEncode(plottingJson);
|
request.fields['plotting'] = jsonEncode(plottingJson);
|
||||||
|
|
||||||
|
// Add file
|
||||||
request.files.add(
|
request.files.add(
|
||||||
await http.MultipartFile.fromPath('photo', imagePath),
|
await http.MultipartFile.fromPath('photo', imagePath),
|
||||||
);
|
);
|
||||||
|
|
||||||
final streamedResponse = await request.send().timeout(
|
// Send request
|
||||||
const Duration(seconds: 15),
|
final response = await request.send();
|
||||||
onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
|
|
||||||
);
|
|
||||||
|
|
||||||
final responseBody = await streamedResponse.stream.bytesToString();
|
if (response.statusCode == 200) {
|
||||||
Map<String, dynamic> responseJson = {};
|
final responseData = await response.stream.bytesToString();
|
||||||
try {
|
debugPrint('Export réussi: $responseData');
|
||||||
responseJson = jsonDecode(responseBody);
|
return true;
|
||||||
} 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 {
|
} else {
|
||||||
return AiExportResult.error(
|
final errorData = await response.stream.bytesToString();
|
||||||
code: responseJson['code'] ?? 'SERVER_ERROR',
|
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
|
||||||
message: responseJson['error'] ?? 'Erreur serveur ($statusCode).',
|
return false;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} 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) {
|
} catch (e) {
|
||||||
debugPrint('Exception lors de l\'export: $e');
|
debugPrint('Exception lors de l\'export: $e');
|
||||||
return AiExportResult.error(
|
return false;
|
||||||
code: 'UNKNOWN_ERROR',
|
|
||||||
message: 'Erreur lors de l\'export: $e',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,426 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
||||||
|
|
||||||
|
/// Résultat du scan de document.
|
||||||
|
class DocumentScanResult {
|
||||||
|
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
|
||||||
|
final String? imagePath;
|
||||||
|
|
||||||
|
/// true si un scan a bien été produit.
|
||||||
|
bool get success => imagePath != null;
|
||||||
|
|
||||||
|
const DocumentScanResult(this.imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service qui lance le scanner de documents Google ML Kit.
|
||||||
|
///
|
||||||
|
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
|
||||||
|
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
|
||||||
|
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
|
||||||
|
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
|
||||||
|
///
|
||||||
|
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
|
||||||
|
class DocumentScannerService {
|
||||||
|
/// Lance le scanner et renvoie le chemin de l'image redressée.
|
||||||
|
///
|
||||||
|
/// [pageLimit] est fixé à 1 (une seule cible par scan).
|
||||||
|
/// On désactive l'import galerie ici car la galerie est gérée séparément.
|
||||||
|
///
|
||||||
|
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
|
||||||
|
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
|
||||||
|
/// rehaussement type photocopie) → détruit les couleurs de la cible.
|
||||||
|
/// - `base` ne fait QUE le recadrage + le redressement de perspective
|
||||||
|
/// (mise de face), en conservant l'image en couleurs réelles. C'est
|
||||||
|
/// indispensable pour pouvoir détecter ensuite les impacts.
|
||||||
|
Future<DocumentScanResult> scanTarget() async {
|
||||||
|
final options = DocumentScannerOptions(
|
||||||
|
mode: ScannerMode.base, // redressement seul, sans filtre couleur
|
||||||
|
pageLimit: 1,
|
||||||
|
isGalleryImport: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final scanner = DocumentScanner(options: options);
|
||||||
|
try {
|
||||||
|
final DocumentScanningResult result = await scanner.scanDocument();
|
||||||
|
final images = result.images;
|
||||||
|
if (images.isEmpty) {
|
||||||
|
return const DocumentScanResult(null);
|
||||||
|
}
|
||||||
|
return DocumentScanResult(images.first);
|
||||||
|
} finally {
|
||||||
|
// Libère les ressources natives du scanner.
|
||||||
|
await scanner.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,37 @@ void _cropIsolateEntry(_CropParams params) {
|
|||||||
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
|
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OPTIMISATION MAJEURE : pré-réduction avant rotation ───────────────────
|
||||||
|
// La rotation (copyRotate) est l'opération la plus lourde du package `image`
|
||||||
|
// et son coût est proportionnel au nombre de pixels. Comme la sortie finale
|
||||||
|
// ne fait que `outputSize` px, on n'a aucun intérêt à faire pivoter une image
|
||||||
|
// de plusieurs dizaines de mégapixels.
|
||||||
|
//
|
||||||
|
// On réduit donc l'image source à une "taille de travail" juste suffisante
|
||||||
|
// AVANT toute rotation/crop. Le crop garde ~85% du cadre, donc on laisse une
|
||||||
|
// marge : workMax = outputSize / 0.7 couvre largement le besoin sans perte
|
||||||
|
// visible. Les coordonnées de crop étant relatives (0..1), elles restent
|
||||||
|
// valables quelle que soit l'échelle.
|
||||||
|
final int workMax = (params.outputSize / 0.7).round();
|
||||||
|
final int longestSide = math.max(originalImage.width, originalImage.height);
|
||||||
|
if (longestSide > workMax) {
|
||||||
|
if (originalImage.width >= originalImage.height) {
|
||||||
|
originalImage = img.copyResize(
|
||||||
|
originalImage,
|
||||||
|
width: workMax,
|
||||||
|
interpolation: img.Interpolation.linear,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
originalImage = img.copyResize(
|
||||||
|
originalImage,
|
||||||
|
height: workMax,
|
||||||
|
interpolation: img.Interpolation.linear,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
|
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
|
||||||
|
// (désormais appliquée sur une image déjà réduite → beaucoup plus rapide)
|
||||||
if (params.rotationDegrees != 0.0) {
|
if (params.rotationDegrees != 0.0) {
|
||||||
originalImage = img.copyRotate(
|
originalImage = img.copyRotate(
|
||||||
originalImage,
|
originalImage,
|
||||||
@@ -98,152 +128,11 @@ void _cropIsolateEntry(_CropParams params) {
|
|||||||
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
||||||
}
|
}
|
||||||
|
|
||||||
// AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir)
|
|
||||||
class _ViewportCropParams {
|
|
||||||
final String sourcePath;
|
|
||||||
final double offsetDx;
|
|
||||||
final double offsetDy;
|
|
||||||
final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain)
|
|
||||||
final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran
|
|
||||||
final double zoomScale; // zoom utilisateur : sert UNIQUEMENT au pointage
|
|
||||||
final double rotationDegrees;
|
|
||||||
final int outputSize;
|
|
||||||
final String outputPath;
|
|
||||||
|
|
||||||
_ViewportCropParams({
|
|
||||||
required this.sourcePath,
|
|
||||||
required this.offsetDx,
|
|
||||||
required this.offsetDy,
|
|
||||||
required this.displayPerSourcePx,
|
|
||||||
required this.cropSizeDisplay,
|
|
||||||
required this.zoomScale,
|
|
||||||
required this.rotationDegrees,
|
|
||||||
required this.outputSize,
|
|
||||||
required this.outputPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation
|
|
||||||
// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute
|
|
||||||
// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur
|
|
||||||
// l'a placée, même collée à un bord (aucun recentrage forcé).
|
|
||||||
void _viewportCropIsolateEntry(_ViewportCropParams p) {
|
|
||||||
final bytes = File(p.sourcePath).readAsBytesSync();
|
|
||||||
final img.Image? src = img.decodeImage(bytes);
|
|
||||||
if (src == null) {
|
|
||||||
throw Exception('Impossible de décoder l\'image: ${p.sourcePath}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels.
|
|
||||||
img.Image rotated = src;
|
|
||||||
if (p.rotationDegrees != 0.0) {
|
|
||||||
rotated = img.copyRotate(
|
|
||||||
src,
|
|
||||||
angle: p.rotationDegrees,
|
|
||||||
interpolation: img.Interpolation.linear,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final double f = p.displayPerSourcePx;
|
|
||||||
// Côté de la fenêtre de visée en pixels source. On utilise f SEUL (pas le
|
|
||||||
// zoom) → le champ de vision capturé est toujours celui de l'image non
|
|
||||||
// zoomée : le zoom n'est donc PAS pris en compte dans le rendu de sortie.
|
|
||||||
final int side = math.max(1, (p.cropSizeDisplay / f).round());
|
|
||||||
|
|
||||||
// Centre de la fenêtre de visée, en pixels de l'image (rotated).
|
|
||||||
// La translation écran s'applique APRÈS le zoom, donc un déplacement écran
|
|
||||||
// `offset` vaut `offset / (f * zoom)` pixels source. On inclut donc le zoom
|
|
||||||
// ICI (uniquement pour le pointage) afin que la croix vise le même point
|
|
||||||
// quel que soit le niveau de zoom.
|
|
||||||
final double fz = f * p.zoomScale;
|
|
||||||
final double cropCenterX = rotated.width / 2 - p.offsetDx / fz;
|
|
||||||
final double cropCenterY = rotated.height / 2 - p.offsetDy / fz;
|
|
||||||
|
|
||||||
final int srcX = (cropCenterX - side / 2).round();
|
|
||||||
final int srcY = (cropCenterY - side / 2).round();
|
|
||||||
|
|
||||||
// Toile carrée NOIRE opaque (numChannels 3 → pixels initialisés à 0 = noir).
|
|
||||||
final out = img.Image(width: side, height: side, numChannels: 3);
|
|
||||||
|
|
||||||
// Intersection de la fenêtre de visée avec l'image réelle. Tout ce qui
|
|
||||||
// déborde reste noir (padding) → aucun recentrage forcé.
|
|
||||||
final int vx0 = math.max(0, srcX);
|
|
||||||
final int vy0 = math.max(0, srcY);
|
|
||||||
final int vx1 = math.min(rotated.width, srcX + side);
|
|
||||||
final int vy1 = math.min(rotated.height, srcY + side);
|
|
||||||
final int vw = vx1 - vx0;
|
|
||||||
final int vh = vy1 - vy0;
|
|
||||||
|
|
||||||
if (vw > 0 && vh > 0) {
|
|
||||||
final region = img.copyCrop(rotated, x: vx0, y: vy0, width: vw, height: vh);
|
|
||||||
// dstW/dstH EXPLICITES = taille de la région → AUCUN redimensionnement
|
|
||||||
// (sinon compositeImage étire la source pour remplir la destination).
|
|
||||||
img.compositeImage(
|
|
||||||
out,
|
|
||||||
region,
|
|
||||||
dstX: vx0 - srcX,
|
|
||||||
dstY: vy0 - srcY,
|
|
||||||
dstW: vw,
|
|
||||||
dstH: vh,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
img.Image result = out;
|
|
||||||
if (side != p.outputSize) {
|
|
||||||
result = img.copyResize(
|
|
||||||
out,
|
|
||||||
width: p.outputSize,
|
|
||||||
height: p.outputSize,
|
|
||||||
interpolation: img.Interpolation.linear,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85));
|
|
||||||
}
|
|
||||||
|
|
||||||
class ImageCropService {
|
class ImageCropService {
|
||||||
final Uuid _uuid = const Uuid();
|
final Uuid _uuid = const Uuid();
|
||||||
|
|
||||||
static const int maxOutputSize = 1024;
|
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(
|
Future<String> cropToSquare(
|
||||||
String sourcePath,
|
String sourcePath,
|
||||||
CropRect cropRect, {
|
CropRect cropRect, {
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/// Service de détection d'impacts utilisant OpenCV.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||||
|
|
||||||
|
/// Paramètres de détection d'impacts OpenCV
|
||||||
|
class OpenCVDetectionSettings {
|
||||||
|
/// Seuil Canny bas pour la détection de contours
|
||||||
|
final double cannyThreshold1;
|
||||||
|
|
||||||
|
/// Seuil Canny haut pour la détection de contours
|
||||||
|
final double cannyThreshold2;
|
||||||
|
|
||||||
|
/// Distance minimale entre les centres des cercles détectés
|
||||||
|
final double minDist;
|
||||||
|
|
||||||
|
/// Paramètre 1 de HoughCircles (seuil Canny interne)
|
||||||
|
final double param1;
|
||||||
|
|
||||||
|
/// Paramètre 2 de HoughCircles (seuil d'accumulation)
|
||||||
|
final double param2;
|
||||||
|
|
||||||
|
/// Rayon minimum des cercles en pixels
|
||||||
|
final int minRadius;
|
||||||
|
|
||||||
|
/// Rayon maximum des cercles en pixels
|
||||||
|
final int maxRadius;
|
||||||
|
|
||||||
|
/// Taille du flou gaussien (doit être impair)
|
||||||
|
final int blurSize;
|
||||||
|
|
||||||
|
/// Utiliser la détection de contours en plus de Hough
|
||||||
|
final bool useContourDetection;
|
||||||
|
|
||||||
|
/// Circularité minimale pour la détection par contours (0-1)
|
||||||
|
final double minCircularity;
|
||||||
|
|
||||||
|
/// Surface minimale des contours
|
||||||
|
final double minContourArea;
|
||||||
|
|
||||||
|
/// Surface maximale des contours
|
||||||
|
final double maxContourArea;
|
||||||
|
|
||||||
|
const OpenCVDetectionSettings({
|
||||||
|
this.cannyThreshold1 = 50,
|
||||||
|
this.cannyThreshold2 = 150,
|
||||||
|
this.minDist = 20,
|
||||||
|
this.param1 = 100,
|
||||||
|
this.param2 = 30,
|
||||||
|
this.minRadius = 5,
|
||||||
|
this.maxRadius = 50,
|
||||||
|
this.blurSize = 5,
|
||||||
|
this.useContourDetection = true,
|
||||||
|
this.minCircularity = 0.6,
|
||||||
|
this.minContourArea = 50,
|
||||||
|
this.maxContourArea = 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résultat de détection d'impact
|
||||||
|
class OpenCVDetectedImpact {
|
||||||
|
/// Position X normalisée (0-1)
|
||||||
|
final double x;
|
||||||
|
|
||||||
|
/// Position Y normalisée (0-1)
|
||||||
|
final double y;
|
||||||
|
|
||||||
|
/// Rayon en pixels
|
||||||
|
final double radius;
|
||||||
|
|
||||||
|
/// Score de confiance (0-1)
|
||||||
|
final double confidence;
|
||||||
|
|
||||||
|
/// Méthode de détection utilisée
|
||||||
|
final String method;
|
||||||
|
|
||||||
|
const OpenCVDetectedImpact({
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
required this.radius,
|
||||||
|
this.confidence = 1.0,
|
||||||
|
this.method = 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service de détection d'impacts utilisant OpenCV
|
||||||
|
class OpenCVImpactDetectionService {
|
||||||
|
/// Détecte les impacts dans une image en utilisant OpenCV
|
||||||
|
List<OpenCVDetectedImpact> detectImpacts(
|
||||||
|
String imagePath, {
|
||||||
|
OpenCVDetectionSettings settings = const OpenCVDetectionSettings(),
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
|
||||||
|
if (img.isEmpty) return [];
|
||||||
|
|
||||||
|
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
|
||||||
|
|
||||||
|
// Apply blur to reduce noise
|
||||||
|
final blurKSize = (settings.blurSize, settings.blurSize);
|
||||||
|
final blurred = cv.gaussianBlur(gray, blurKSize, 2, sigmaY: 2);
|
||||||
|
|
||||||
|
final List<OpenCVDetectedImpact> detectedImpacts = [];
|
||||||
|
|
||||||
|
final circles = cv.HoughCircles(
|
||||||
|
blurred,
|
||||||
|
cv.HOUGH_GRADIENT,
|
||||||
|
1,
|
||||||
|
settings.minDist,
|
||||||
|
param1: settings.param1,
|
||||||
|
param2: settings.param2,
|
||||||
|
minRadius: settings.minRadius,
|
||||||
|
maxRadius: settings.maxRadius,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (circles.rows > 0 && circles.cols > 0) {
|
||||||
|
// Mat shape: (1, N, 3) usually for HoughCircles (CV_32FC3)
|
||||||
|
// We use at<Vec3f> directly.
|
||||||
|
|
||||||
|
for (int i = 0; i < circles.cols; i++) {
|
||||||
|
final vec = circles.at<cv.Vec3f>(0, i);
|
||||||
|
final x = vec.val1;
|
||||||
|
final y = vec.val2;
|
||||||
|
final r = vec.val3;
|
||||||
|
|
||||||
|
detectedImpacts.add(
|
||||||
|
OpenCVDetectedImpact(
|
||||||
|
x: x / img.cols,
|
||||||
|
y: y / img.rows,
|
||||||
|
radius: r,
|
||||||
|
confidence: 0.8,
|
||||||
|
method: 'hough',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Contour Detection (if enabled)
|
||||||
|
if (settings.useContourDetection) {
|
||||||
|
// Canny edge detection
|
||||||
|
final edges = cv.canny(
|
||||||
|
blurred,
|
||||||
|
settings.cannyThreshold1,
|
||||||
|
settings.cannyThreshold2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find contours
|
||||||
|
final contoursResult = cv.findContours(
|
||||||
|
edges,
|
||||||
|
cv.RETR_EXTERNAL,
|
||||||
|
cv.CHAIN_APPROX_SIMPLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
final contours = contoursResult.$1;
|
||||||
|
// hierarchy is $2
|
||||||
|
|
||||||
|
for (int i = 0; i < contours.length; i++) {
|
||||||
|
final contour = contours[i];
|
||||||
|
|
||||||
|
// Filter by area
|
||||||
|
final area = cv.contourArea(contour);
|
||||||
|
if (area < settings.minContourArea ||
|
||||||
|
area > settings.maxContourArea) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by circularity
|
||||||
|
final perimeter = cv.arcLength(contour, true);
|
||||||
|
if (perimeter == 0) continue;
|
||||||
|
final circularity = 4 * math.pi * area / (perimeter * perimeter);
|
||||||
|
|
||||||
|
if (circularity < settings.minCircularity) continue;
|
||||||
|
|
||||||
|
// Get bounding circle
|
||||||
|
final enclosingCircle = cv.minEnclosingCircle(contour);
|
||||||
|
final center = enclosingCircle.$1;
|
||||||
|
final radius = enclosingCircle.$2;
|
||||||
|
|
||||||
|
// Avoid duplicates (simple distance check against Hough results)
|
||||||
|
bool isDuplicate = false;
|
||||||
|
for (final existing in detectedImpacts) {
|
||||||
|
final dx = existing.x * img.cols - center.x;
|
||||||
|
final dy = existing.y * img.rows - center.y;
|
||||||
|
final dist = math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < radius) {
|
||||||
|
isDuplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDuplicate) {
|
||||||
|
detectedImpacts.add(
|
||||||
|
OpenCVDetectedImpact(
|
||||||
|
x: center.x / img.cols,
|
||||||
|
y: center.y / img.rows,
|
||||||
|
radius: radius,
|
||||||
|
confidence: circularity, // Use circularity as confidence
|
||||||
|
method: 'contour',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return detectedImpacts;
|
||||||
|
} catch (e) {
|
||||||
|
// print('OpenCV Error: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Détecte les impacts en utilisant une image de référence
|
||||||
|
List<OpenCVDetectedImpact> detectFromReferences(
|
||||||
|
String imagePath,
|
||||||
|
List<({double x, double y})> referencePoints, {
|
||||||
|
double tolerance = 2.0,
|
||||||
|
}) {
|
||||||
|
// Basic implementation: use average color/brightness of reference points
|
||||||
|
// This is a placeholder for a more complex template matching or feature matching
|
||||||
|
|
||||||
|
// For now, we can just run the standard detection but filter results
|
||||||
|
// based on properties of the reference points (e.g. size/radius if we had it).
|
||||||
|
|
||||||
|
// Returning standard detection for now to enable the feature.
|
||||||
|
return detectImpacts(imagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,34 +26,23 @@ class TargetDetectionResult {
|
|||||||
|
|
||||||
class OpenCVTargetService {
|
class OpenCVTargetService {
|
||||||
/// Detect the main target (center and radius) from an image file
|
/// Detect the main target (center and radius) from an image file
|
||||||
///
|
|
||||||
/// IMPORTANT : les Mat OpenCV sont de la mémoire NATIVE, invisible pour le
|
|
||||||
/// garbage collector Dart. Cette méthode est appelée en boucle (~1 s)
|
|
||||||
/// pendant l'aperçu caméra : sans dispose() explicite dans le finally, la
|
|
||||||
/// mémoire native grimpe en continu tant que l'utilisateur vise.
|
|
||||||
Future<TargetDetectionResult> detectTarget(String imagePath) async {
|
Future<TargetDetectionResult> detectTarget(String imagePath) async {
|
||||||
cv.Mat? img;
|
|
||||||
cv.Mat? gray;
|
|
||||||
cv.Mat? blurred;
|
|
||||||
cv.Mat? circles;
|
|
||||||
cv.Mat? looseCircles;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read image
|
// Read image
|
||||||
img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
|
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
|
||||||
if (img.isEmpty) {
|
if (img.isEmpty) {
|
||||||
return TargetDetectionResult.failure();
|
return TargetDetectionResult.failure();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to grayscale
|
// Convert to grayscale
|
||||||
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
|
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
|
||||||
|
|
||||||
// Apply Gaussian blur to reduce noise
|
// Apply Gaussian blur to reduce noise
|
||||||
blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
|
final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
|
||||||
|
|
||||||
// Detect circles using Hough Transform.
|
// Detect circles using Hough Transform
|
||||||
// HoughCircles returns a Mat of shape (1, N) of Vec3f (x, y, r).
|
// Parameters need to be tuned for the specific target type
|
||||||
circles = cv.HoughCircles(
|
final circles = cv.HoughCircles(
|
||||||
blurred,
|
blurred,
|
||||||
cv.HOUGH_GRADIENT,
|
cv.HOUGH_GRADIENT,
|
||||||
1, // dp
|
1, // dp
|
||||||
@@ -66,9 +55,26 @@ class OpenCVTargetService {
|
|||||||
maxRadius: img.cols ~/ 2,
|
maxRadius: img.cols ~/ 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles.
|
||||||
|
// In opencv_dart, we cannot iterate easily.
|
||||||
|
// However, we can access data via pointer if needed, or check if Vec3f is supported.
|
||||||
|
// Given the user report, `at<Vec3f>` likely failed compilation or runtime.
|
||||||
|
// Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...).
|
||||||
|
// Or use `at<double>` carefully.
|
||||||
|
|
||||||
|
// Better yet: try to use `circles.data` if available, but it returns a Pointer.
|
||||||
|
// Let's stick to `at` but use `double` and manual offset if Vec3f fails.
|
||||||
|
// actually, let's try to trust `at<double>` for flattened access OR `at<Vec3f>`.
|
||||||
|
// NOTE: `at<Vec3f>` was reported as "method at not defined for VecPoint2f" earlier, NOT for Mat.
|
||||||
|
// The user error was for `VecPoint2f`. `Mat` definitely has `at`.
|
||||||
|
// BUT `VecPoint2f` is a List-like structure in Dart wrapper.
|
||||||
|
// usage of `at` on `VecPoint2f` was the error.
|
||||||
|
// Here `circles` IS A MAT. So `at` IS defined.
|
||||||
|
// However, to be safe and robust, and to implement clustering...
|
||||||
|
|
||||||
if (circles.isEmpty) {
|
if (circles.isEmpty) {
|
||||||
// Try with different parameters if first attempt fails (more lenient)
|
// Try with different parameters if first attempt fails (more lenient)
|
||||||
looseCircles = cv.HoughCircles(
|
final looseCircles = cv.HoughCircles(
|
||||||
blurred,
|
blurred,
|
||||||
cv.HOUGH_GRADIENT,
|
cv.HOUGH_GRADIENT,
|
||||||
1,
|
1,
|
||||||
@@ -87,15 +93,8 @@ class OpenCVTargetService {
|
|||||||
|
|
||||||
return _findBestConcentricCircles(circles, img.cols, img.rows);
|
return _findBestConcentricCircles(circles, img.cols, img.rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// print('Error detecting target with OpenCV: $e');
|
||||||
return TargetDetectionResult.failure();
|
return TargetDetectionResult.failure();
|
||||||
} finally {
|
|
||||||
// _findBestConcentricCircles a déjà extrait les données dans des listes
|
|
||||||
// Dart avant qu'on arrive ici : libérer les Mat est donc toujours sûr.
|
|
||||||
img?.dispose();
|
|
||||||
gray?.dispose();
|
|
||||||
blurred?.dispose();
|
|
||||||
circles?.dispose();
|
|
||||||
looseCircles?.dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ class ParallelismService {
|
|||||||
|
|
||||||
// Normalisation par la magnitude réelle (indépendant de g exact)
|
// Normalisation par la magnitude réelle (indépendant de g exact)
|
||||||
final double nx = gx / magnitude;
|
final double nx = gx / magnitude;
|
||||||
|
final double ny = gy / magnitude;
|
||||||
final double nz = gz / magnitude;
|
final double nz = gz / magnitude;
|
||||||
|
|
||||||
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
|
||||||
|
|||||||