Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2adb3885d9 | ||
|
|
25caf6ddf8 | ||
|
|
a9651588bb | ||
|
|
32582aba3d | ||
|
|
32143c5bb1 | ||
|
|
6ee0839db5 | ||
|
|
8804d8b6a1 | ||
|
|
869835f234 | ||
|
|
3d9e574309 | ||
|
|
3f79252bb5 | ||
|
|
f6f134f2a5 | ||
|
|
56e88c3e06 | ||
|
|
0ab9043d82 | ||
|
|
eeb857d452 | ||
|
|
6dc525c0db | ||
|
|
96cc487b55 | ||
|
|
8dc6542603 | ||
|
|
e943538133 | ||
|
|
7f1fa2d80b | ||
|
|
a2f7bfc158 | ||
|
|
d0a7700d02 | ||
|
|
9b52623ebe | ||
|
|
ab12e07847 | ||
|
|
98b9f1cd4c | ||
|
|
e889456bfa | ||
|
|
c0177b19e3 | ||
|
|
99abf60b52 | ||
|
|
6e09ea25dd | ||
|
|
9a429d476d | ||
|
|
7923d1b2b2 | ||
|
|
7525c7e368 | ||
|
|
e111f76731 | ||
|
|
5d7d5e6b54 | ||
|
|
bc77462c27 | ||
|
|
4437a1f436 | ||
|
|
1b2310b12b | ||
|
|
44ac4462a6 | ||
|
|
0374a7611e | ||
|
|
543f54dc4f | ||
|
|
3812cd740b | ||
|
|
86abb645ce | ||
|
|
3a9e393a20 | ||
|
|
058bd5ff71 | ||
|
|
a4dc26fda8 | ||
|
|
6130e63cfb | ||
|
|
b641e80d62 | ||
|
|
df5f84c87c | ||
|
|
bbf10d7eed | ||
|
|
2b904205a0 | ||
|
|
cec9838e9b | ||
|
|
8d5641c0a3 | ||
|
|
a3167fc2ba | ||
|
|
972bfbe0e9 | ||
|
|
f65f65112c | ||
|
|
beb20074f2 | ||
|
|
a102bfd5ef | ||
|
|
89b4f433b6 | ||
|
|
9fa3a6f46d | ||
|
|
8165d3bab3 | ||
|
|
ba9975f047 | ||
|
|
f7fecf0ef2 | ||
|
|
0564bdbb48 | ||
|
|
05356aaaea | ||
|
|
c4880ccb68 | ||
|
|
00ae0117c5 | ||
|
|
1d8124c8d8 | ||
|
|
600a6bef30 | ||
|
|
5d2e612b9c | ||
|
|
4e26431be5 | ||
|
|
559369c84d | ||
|
|
653aa5d5b0 | ||
|
|
fec33a327a | ||
|
|
8e64839fc6 | ||
|
|
331fda0ffb | ||
|
|
064f5c1fe3 | ||
|
|
b4226216eb | ||
|
|
2107f187b6 | ||
|
|
0c509bfdc3 | ||
|
|
71ad670ad8 | ||
|
|
625810234e | ||
|
|
9afe3c8508 | ||
|
|
f0b15941cf | ||
|
|
9f9f9beb84 | ||
|
|
c49c8b8c73 | ||
|
|
61b5fcfe22 | ||
|
|
f65c5e6a3f | ||
|
|
d487c98e3d | ||
|
|
9cb37fc303 | ||
|
|
3ae9ceebc1 | ||
|
|
251d4bb599 | ||
|
|
ccc6eb609a | ||
|
|
9e0427f750 | ||
|
|
a5d0251545 | ||
|
|
730629a031 | ||
|
|
4d2ca2c94f | ||
|
|
ab45786d0c | ||
|
|
95e62abe47 | ||
|
|
04c1204116 | ||
|
|
78c9fc96c9 | ||
|
|
e570bd4296 | ||
|
|
02c0d449e4 | ||
|
|
eabe7511bd | ||
|
|
620e12a1fd | ||
|
|
622f2936de | ||
|
|
814f778e6c | ||
|
|
2f493ef622 | ||
|
|
33e1522df0 | ||
|
|
653264c6a1 | ||
|
|
750732eafe | ||
|
|
f40bfc0ba7 | ||
|
|
b721583e98 | ||
|
|
18c01c3767 | ||
|
|
0af6a0d8e2 | ||
|
|
fbd631c8e4 | ||
|
|
3088c5ee91 | ||
|
|
fe6d52902b | ||
|
|
48b8313be8 | ||
|
|
a9ff826246 | ||
|
|
f149fe092b | ||
|
|
774dbfcf40 | ||
|
|
5dd58da51c | ||
|
|
8910a0a87e | ||
|
|
0895b7d5bc | ||
|
|
6892d55c65 | ||
|
|
8946955f76 | ||
|
|
be62669d56 | ||
|
|
db6dd0cee5 | ||
|
|
d98f54cf93 | ||
|
|
55f6210c97 | ||
|
|
8d9b35104a | ||
|
|
5cc67228e4 | ||
|
|
cea2fab989 | ||
|
|
105eb1cab0 | ||
|
|
fba3b41f2f | ||
|
|
e32833e366 | ||
|
|
d4cb179fde | ||
|
|
2e81f4b69e | ||
|
|
de677aad7e | ||
|
|
723900b860 | ||
|
|
f78184d2cd | ||
|
|
7e55c52ae7 | ||
|
|
049f77ac6d | ||
|
|
91fcd931e8 |
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(flutter analyze:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(flutter clean:*)",
|
|
||||||
"Bash(flutter pub get:*)",
|
|
||||||
"Bash(flutter run:*)",
|
|
||||||
"Bash(cmake:*)",
|
|
||||||
"Bash(where:*)",
|
|
||||||
"Bash(winget search:*)",
|
|
||||||
"Bash(winget install:*)",
|
|
||||||
"Bash(\"/c/Program Files \\(x86\\)/Microsoft Visual Studio/Installer/vs_installer.exe\" modify --installPath \"C:\\\\Program Files \\(x86\\)\\\\Microsoft Visual Studio\\\\2022\\\\BuildTools\" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait)",
|
|
||||||
"Bash(cmd //c \"\"\"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\18\\\\Community\\\\Common7\\\\Tools\\\\VsDevCmd.bat\"\" && flutter run -d windows\")",
|
|
||||||
"Bash(flutter doctor:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: Build & Release Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
- 'V*' # Se déclenche quand vous poussez un tag comme v1.0.0, v1.0.1...
|
||||||
|
workflow_dispatch: # Permet aussi de lancer la compilation manuellement depuis l'interface Gitea
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-apk:
|
||||||
|
# Utilise votre runner hôte avec Docker
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: 📥 Récupération du code
|
||||||
|
run: |
|
||||||
|
echo "📥 Clonage du code source dans /data/build..."
|
||||||
|
rm -rf /data/build
|
||||||
|
git config --global --add safe.directory "*"
|
||||||
|
git clone --depth 1 "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" /data/build
|
||||||
|
|
||||||
|
- name: 🔨 Compilation de l'APK (Conteneur Flutter / Android SDK avec Caches)
|
||||||
|
run: |
|
||||||
|
echo "🚀 Démarrage de la compilation Flutter avec Caches persistants..."
|
||||||
|
mkdir -p /data/caches/gradle /data/caches/pub /data/caches/android-ndk /data/caches/android-cmake /data/caches/android-platforms
|
||||||
|
|
||||||
|
# Montage des caches persistants : Gradle, Pub, NDK, CMake et Plateformes Android
|
||||||
|
docker run --rm \
|
||||||
|
--volumes-from gitea-act-runner \
|
||||||
|
-v /data/caches/gradle:/root/.gradle \
|
||||||
|
-v /data/caches/pub:/root/.pub-cache \
|
||||||
|
-v /data/caches/android-ndk:/opt/android-sdk-linux/ndk \
|
||||||
|
-v /data/caches/android-cmake:/opt/android-sdk-linux/cmake \
|
||||||
|
-v /data/caches/android-platforms:/opt/android-sdk-linux/platforms \
|
||||||
|
-w /data/build \
|
||||||
|
ghcr.io/cirruslabs/flutter:stable \
|
||||||
|
sh -c "git config --global --add safe.directory '*' && flutter pub get && flutter build apk --release"
|
||||||
|
|
||||||
|
echo "✅ APK généré avec succès dans /data/build/build/app/outputs/flutter-apk/app-release.apk"
|
||||||
|
|
||||||
|
- name: 📦 Publication de la Release sur Gitea & Upload de l'APK
|
||||||
|
run: |
|
||||||
|
which curl >/dev/null 2>&1 || apk add --no-cache curl
|
||||||
|
TAG_NAME="${{ github.ref_name }}"
|
||||||
|
if [ -z "$TAG_NAME" ] || [ "$TAG_NAME" = "main" ]; then
|
||||||
|
TAG_NAME="build-$(date +'%Y%m%d-%H%M%S')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
APK_PATH="/data/build/build/app/outputs/flutter-apk/app-release.apk"
|
||||||
|
APK_NAME="bully-impact-${TAG_NAME}.apk"
|
||||||
|
|
||||||
|
echo "🚀 Création de la release $TAG_NAME sur Gitea..."
|
||||||
|
|
||||||
|
# 1. Création de la Release via l'API REST de Gitea
|
||||||
|
RELEASE_RESPONSE=$(curl -s -X POST "https://git.kevlar.cloud/api/v1/repos/${{ github.repository }}/releases" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"${TAG_NAME}\",
|
||||||
|
\"name\": \"Version ${TAG_NAME}\",
|
||||||
|
\"body\": \"Nouvelle version de l'application Bully Impact générée automatiquement par CI/CD.\",
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "❌ Erreur lors de la création de la release: $RELEASE_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Upload du fichier APK (Release ID: $RELEASE_ID)..."
|
||||||
|
|
||||||
|
# 2. Upload de l'APK en tant qu'asset téléchargeable
|
||||||
|
curl -s -X POST "https://git.kevlar.cloud/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${APK_NAME}" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/vnd.android.package-archive" \
|
||||||
|
--data-binary @"$APK_PATH"
|
||||||
|
|
||||||
|
echo "🎉 Release terminée avec succès ! APK téléchargeable sur Gitea."
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Deploy Backendia
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'backendia/**'
|
||||||
|
- 'docker-compose.prod.yml'
|
||||||
|
- '.gitea/workflows/deploy.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Correspond au label 'docker:host' de votre runner
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: 📥 Récupération du code
|
||||||
|
run: |
|
||||||
|
echo "📥 Récupération du code..."
|
||||||
|
git clone --depth 1 --branch ${{ github.ref_name }} "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" .
|
||||||
|
|
||||||
|
- name: 🚀 Build et Déploiement Docker
|
||||||
|
run: |
|
||||||
|
echo "🚀 Démarrage du déploiement..."
|
||||||
|
docker rm -f backendia-prod 2>/dev/null || true
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans --force-recreate
|
||||||
|
echo "🧹 Nettoyage des anciennes images inutilisées..."
|
||||||
|
docker image prune -f
|
||||||
|
echo "✅ Déploiement terminé avec succès !"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
.svn/
|
.svn/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
@@ -43,3 +44,16 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Backendia
|
||||||
|
backendia/node_modules/
|
||||||
|
backendia/dashboard/node_modules/
|
||||||
|
backendia/dashboard/.next/
|
||||||
|
backendia/dashboard/out/
|
||||||
|
backendia/exports/*.zip
|
||||||
|
backendia/uploads/images/*
|
||||||
|
!backendia/uploads/images/.gitkeep
|
||||||
|
backendia/uploads/data/*
|
||||||
|
!backendia/uploads/data/.gitkeep
|
||||||
|
!backendia/uploads/data/database.sqlite
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"avdmanager.executable": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\avdmanager.bat",
|
||||||
|
"avdmanager.sdkManager": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\sdkmanager.bat"
|
||||||
|
}
|
||||||
@@ -1,5 +1,40 @@
|
|||||||
# 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,10 +41,12 @@ flutter test --coverage
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Analyse de cibles de tir
|
### Capture de cibles de tir
|
||||||
- Support de cibles concentriques (anneaux) et silhouettes
|
- Support de cibles concentriques (anneaux) et silhouettes
|
||||||
- Chargement d'images de cibles depuis la galerie ou la caméra
|
- Chargement d'images de cibles depuis la galerie ou la caméra
|
||||||
- Détection automatique du centre et du rayon de la cible
|
- Aperçu caméra avec aide au cadrage : détection OpenCV de la cible (mire)
|
||||||
|
et indicateur de parallélisme par accéléromètre (pitch/roll)
|
||||||
|
- Écran de centrage/recadrage : rotation fine, déplacement pixel par pixel
|
||||||
|
|
||||||
### Calibration des cibles
|
### Calibration des cibles
|
||||||
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
|
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
|
||||||
@@ -52,14 +54,13 @@ flutter test --coverage
|
|||||||
- Slider global pour redimensionner tous les anneaux proportionnellement
|
- Slider global pour redimensionner tous les anneaux proportionnellement
|
||||||
- Visualisation en temps réel des zones de score
|
- Visualisation en temps réel des zones de score
|
||||||
|
|
||||||
### Détection d'impacts
|
### Placement des impacts
|
||||||
- **Ajout manuel** : cliquer sur l'image pour placer un impact
|
- **Éditeur d'impacts plein écran** : tap pour ajouter (y compris juste à côté
|
||||||
- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables
|
ou par-dessus un impact existant), appui long pour déplacer, pincer pour zoomer
|
||||||
- Seuil de luminosité
|
- Un tap n'ouvre jamais d'édition de score : le score reste calculé
|
||||||
- Taille min/max des impacts
|
automatiquement d'après la position de l'impact
|
||||||
- Circularité minimale
|
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de synthèse
|
||||||
- Ratio de remplissage (distingue les trous pleins des cercles vides)
|
efface tous les impacts sans toucher à la calibration
|
||||||
- **Détection par références** : sélectionner 2-4 impacts manuellement, l'algorithme apprend leurs caractéristiques et détecte les impacts similaires
|
|
||||||
|
|
||||||
### Calcul des scores
|
### Calcul des scores
|
||||||
- Score automatique basé sur la position de l'impact dans les zones
|
- Score automatique basé sur la position de l'impact dans les zones
|
||||||
@@ -79,11 +80,37 @@ 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
|
||||||
- Suppression de sessions
|
- Suppression de sessions
|
||||||
|
|
||||||
|
### Didacticiel (visite guidée)
|
||||||
|
- Démarre automatiquement à la **première utilisation** sur l'écran d'accueil :
|
||||||
|
voile sombre, « trou de lumière » autour de l'élément à découvrir et bulle
|
||||||
|
explicative (bouton nouvelle session, télémétrie, barre de navigation, réglages)
|
||||||
|
- Visite dédiée à la **première ouverture de l'éditeur d'impacts** : ajouter,
|
||||||
|
déplacer, **pincer pour zoomer**, valider
|
||||||
|
- **Main animée** qui mime le geste attendu (tap, appui long, glisser,
|
||||||
|
pincement à deux doigts, balayage), dessinée au CustomPainter — aucun asset
|
||||||
|
- « Passer » interrompt la visite, un tap n'importe où passe à l'étape suivante ;
|
||||||
|
chaque visite n'est jouée qu'une fois (mémorisée dans les SharedPreferences)
|
||||||
|
- **Paramètres > Aide & didacticiel > Revoir le didacticiel** : réinitialise
|
||||||
|
toutes les visites, qui rejouent dès le retour sur l'écran concerné
|
||||||
|
|
||||||
### Interface utilisateur
|
### Interface utilisateur
|
||||||
- Thème sombre adapté au tir
|
- Thème sombre adapté au tir
|
||||||
- Support multilingue (Français)
|
- Support multilingue (Français)
|
||||||
@@ -120,3 +147,10 @@ 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
|
||||||
|
tutorial_service.dart Persistance des visites guidées déjà vues (SharedPreferences)
|
||||||
|
tutorial_provider.dart État du didacticiel : visites à jouer, réactivation
|
||||||
|
tutorial_coach.dart Lance une visite guidée par-dessus l'écran courant
|
||||||
|
tutorial_step.dart Modèle d'étape : cible, texte, geste, forme du spot
|
||||||
|
tutorial_overlay.dart Voile sombre, trou de lumière et bulle explicative
|
||||||
|
tutorial_hand.dart Main animée qui mime le geste (tap, appui long, pincement…)
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
|
|
||||||
# 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:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ android {
|
|||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# TensorFlow Lite
|
||||||
|
-keep class org.tensorflow.lite.** { *; }
|
||||||
|
-keep class com.google.android.gms.tflite.** { *; }
|
||||||
|
-dontwarn org.tensorflow.lite.**
|
||||||
|
|
||||||
|
# Specifically for the GPU delegate error
|
||||||
|
-dontwarn org.tensorflow.lite.gpu.GpuDelegateFactory$Options
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
<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" />
|
||||||
|
|
||||||
|
<!-- Pour Android 12 et inférieur -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<!-- Pour Android 13 et supérieur -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<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"
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
dashboard/node_modules
|
||||||
|
dashboard/.next
|
||||||
|
uploads
|
||||||
|
exports
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
dashboard/node_modules/
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
dashboard/.next/
|
||||||
|
dashboard/out/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Database (contient des données utilisateurs — jamais dans git)
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
|
||||||
|
# Uploads and Exports (Ignore content but keep directories)
|
||||||
|
uploads/images/*
|
||||||
|
!uploads/images/.gitkeep
|
||||||
|
uploads/data/*
|
||||||
|
!uploads/data/.gitkeep
|
||||||
|
exports/*
|
||||||
|
!exports/.gitkeep
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dépendances système pour les modules natifs (sharp, sqlite3, opencv)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY dashboard/package*.json ./dashboard/
|
||||||
|
|
||||||
|
RUN npm install && npm rebuild sqlite3 --build-from-source
|
||||||
|
RUN cd dashboard && npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build du Dashboard Next.js
|
||||||
|
RUN cd dashboard && npm run build
|
||||||
|
|
||||||
|
# Dossiers nécessaires
|
||||||
|
RUN mkdir -p uploads/images uploads/data exports
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: '3000',
|
||||||
|
pathname: '/uploads/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
|
"next": "16.2.4",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.4",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,104 @@
|
|||||||
|
import { fetchApi } from "@/lib/api";
|
||||||
|
import { Users, Award, Camera, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface Contributor {
|
||||||
|
wallet_hash: string;
|
||||||
|
photo_count: number;
|
||||||
|
last_upload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ContributorsPage() {
|
||||||
|
let contributors: Contributor[] = [];
|
||||||
|
try {
|
||||||
|
const response = await fetchApi('/api/contributors');
|
||||||
|
contributors = response.contributors;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contributors:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Top Contributeurs</h2>
|
||||||
|
<p className="text-slate-400">Classement des utilisateurs selon leur contribution à l'entraînement de l'IA.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Stats Summary */}
|
||||||
|
<div className="lg:col-span-1 space-y-6">
|
||||||
|
<StatCard
|
||||||
|
title="Total Contributeurs"
|
||||||
|
value={contributors.length}
|
||||||
|
icon={<Users className="text-indigo-400" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Photos"
|
||||||
|
value={contributors.reduce((acc, c) => acc + c.photo_count, 0)}
|
||||||
|
icon={<Camera className="text-emerald-400" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="lg:col-span-3 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-slate-950/50 border-b border-slate-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest">Rang</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest">Wallet Hash</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest text-center">Photos</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest text-right">Dernier Upload</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{contributors.map((c, index) => (
|
||||||
|
<tr key={c.wallet_hash} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{index < 3 ? (
|
||||||
|
<Award className={index === 0 ? "text-yellow-500" : index === 1 ? "text-slate-400" : "text-amber-700"} />
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 font-mono w-6 text-center">#{index + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="font-mono text-xs text-slate-300">{c.wallet_hash}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="px-2 py-1 bg-indigo-500/10 text-indigo-400 rounded-md text-xs font-bold border border-indigo-500/20">
|
||||||
|
{c.photo_count}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right text-xs text-slate-400 flex items-center justify-end gap-2">
|
||||||
|
<Clock size={14} />
|
||||||
|
{new Date(c.last_upload).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{contributors.length === 0 && (
|
||||||
|
<div className="py-24 text-center text-slate-500">
|
||||||
|
Aucun contributeur enregistré pour le moment.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon }: any) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-6 rounded-2xl flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">{title}</p>
|
||||||
|
<p className="text-3xl font-bold">{value}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-slate-800 rounded-xl flex items-center justify-center shadow-inner">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LayoutDashboard, Users, ScrollText, Image as ImageIcon } from "lucide-react";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Bully IA Dashboard",
|
||||||
|
description: "Visualisation des données d'analyse de cibles",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="fr" className="dark">
|
||||||
|
<body className={`${inter.className} bg-slate-950 text-slate-50 min-h-screen flex`}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 border-r border-slate-800 bg-slate-900/50 p-6 flex flex-col gap-8">
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center font-bold">B</div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">Bully IA</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={20} />
|
||||||
|
Tableau de bord
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/contributors"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
Contributeurs
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/logs"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<ScrollText size={20} />
|
||||||
|
Logs d'Uploads
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-6 border-t border-slate-800">
|
||||||
|
<p className="text-xs text-slate-500 px-2">v1.0.0 Alpha</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex flex-col">
|
||||||
|
<header className="h-16 border-b border-slate-800 flex items-center px-8 bg-slate-900/20">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||||
|
<span>Admin</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-slate-200">Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 p-8 overflow-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,900 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
ScrollText,
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Trash2,
|
||||||
|
Smartphone,
|
||||||
|
Crosshair,
|
||||||
|
Calendar,
|
||||||
|
HardDrive,
|
||||||
|
Users,
|
||||||
|
ExternalLink,
|
||||||
|
Info,
|
||||||
|
Filter,
|
||||||
|
Eye,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
Ban,
|
||||||
|
AlertTriangle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface UploadLog {
|
||||||
|
id: number;
|
||||||
|
timestamp: string;
|
||||||
|
session_id: string | null;
|
||||||
|
wallet_hash: string | null;
|
||||||
|
image_filename: string | null;
|
||||||
|
json_filename: string | null;
|
||||||
|
file_size: number | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
device_model: string | null;
|
||||||
|
device_os: string | null;
|
||||||
|
target_type: string | null;
|
||||||
|
weapon: string | null;
|
||||||
|
distance_meters: number | null;
|
||||||
|
impacts_count: number;
|
||||||
|
status: string;
|
||||||
|
target_valid?: number;
|
||||||
|
target_status?: string;
|
||||||
|
target_confidence?: number;
|
||||||
|
target_rings_count?: number;
|
||||||
|
target_details?: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
raw_metadata: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BannedWallet {
|
||||||
|
wallet_hash: string;
|
||||||
|
reason: string;
|
||||||
|
banned_at: string;
|
||||||
|
banned_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogStats {
|
||||||
|
total_uploads: number;
|
||||||
|
success_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
today_uploads: number;
|
||||||
|
unique_wallets: number;
|
||||||
|
total_bytes: number;
|
||||||
|
latest_upload: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsManager({
|
||||||
|
initialLogs,
|
||||||
|
initialStats
|
||||||
|
}: {
|
||||||
|
initialLogs: UploadLog[];
|
||||||
|
initialStats: LogStats | null;
|
||||||
|
}) {
|
||||||
|
const [logs, setLogs] = useState<UploadLog[]>(initialLogs);
|
||||||
|
const [stats, setStats] = useState<LogStats | null>(initialStats);
|
||||||
|
const [bannedWallets, setBannedWallets] = useState<BannedWallet[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"ALL" | "SUCCESS" | "FAILED" | "INVALID_TARGET" | "BANNED">("ALL");
|
||||||
|
const [copiedWallet, setCopiedWallet] = useState<string | null>(null);
|
||||||
|
const [selectedLog, setSelectedLog] = useState<UploadLog | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Moderation state
|
||||||
|
const [banModalWallet, setBanModalWallet] = useState<string | null>(null);
|
||||||
|
const [banReason, setBanReason] = useState("Images non conformes / Fausse cible");
|
||||||
|
const [isSubmittingBan, setIsSubmittingBan] = useState(false);
|
||||||
|
|
||||||
|
const fetchBannedWallets = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/moderation/banned`, { cache: 'no-store' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setBannedWallets(data.banned || []);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur récupération wallets bannis:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBannedWallets();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [logsRes, statsRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/api/logs?limit=200`, { cache: 'no-store' }),
|
||||||
|
fetch(`${API_BASE_URL}/api/logs/stats`, { cache: 'no-store' }),
|
||||||
|
fetchBannedWallets()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (logsRes.ok) {
|
||||||
|
const logsData = await logsRes.json();
|
||||||
|
setLogs(logsData.logs || []);
|
||||||
|
}
|
||||||
|
if (statsRes.ok) {
|
||||||
|
const statsData = await statsRes.json();
|
||||||
|
setStats(statsData.stats || null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur lors de la récupération des logs:", e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopiedWallet(text);
|
||||||
|
setTimeout(() => setCopiedWallet(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWalletBanned = (walletHash: string | null) => {
|
||||||
|
if (!walletHash) return false;
|
||||||
|
return bannedWallets.some(b => b.wallet_hash === walletHash);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBannedInfo = (walletHash: string | null) => {
|
||||||
|
if (!walletHash) return null;
|
||||||
|
return bannedWallets.find(b => b.wallet_hash === walletHash) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBanWallet = async () => {
|
||||||
|
if (!banModalWallet) return;
|
||||||
|
setIsSubmittingBan(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/moderation/ban`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
wallet_hash: banModalWallet,
|
||||||
|
reason: banReason,
|
||||||
|
banned_by: 'Admin Dashboard'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setBanModalWallet(null);
|
||||||
|
await fetchBannedWallets();
|
||||||
|
await fetchLogs();
|
||||||
|
} else {
|
||||||
|
alert("Erreur lors du bannissement.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur ban:", e);
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingBan(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnbanWallet = async (walletHash: string) => {
|
||||||
|
if (!confirm(`Voulez-vous vraiment débannir le wallet ${walletHash.substring(0, 10)}... ?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/moderation/ban/${walletHash}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await fetchBannedWallets();
|
||||||
|
await fetchLogs();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur unban:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLog = async (id: number) => {
|
||||||
|
if (!confirm("Voulez-vous vraiment supprimer ce log ?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/logs/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
setLogs(prev => prev.filter(l => l.id !== id));
|
||||||
|
fetchLogs();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur suppression:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/logs`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
setLogs([]);
|
||||||
|
setShowClearConfirm(false);
|
||||||
|
fetchLogs();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur vidage logs:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number | null) => {
|
||||||
|
if (!bytes || bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredLogs = useMemo(() => {
|
||||||
|
return logs.filter(log => {
|
||||||
|
// Filter status
|
||||||
|
if (statusFilter === "SUCCESS" && log.status !== "SUCCESS") return false;
|
||||||
|
if (statusFilter === "FAILED" && log.status !== "FAILED") return false;
|
||||||
|
if (statusFilter === "INVALID_TARGET" && log.target_status === "VALID") return false;
|
||||||
|
if (statusFilter === "BANNED" && !isWalletBanned(log.wallet_hash)) return false;
|
||||||
|
|
||||||
|
// Filter search
|
||||||
|
if (!searchTerm.trim()) return true;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
(log.wallet_hash && log.wallet_hash.toLowerCase().includes(term)) ||
|
||||||
|
(log.session_id && log.session_id.toLowerCase().includes(term)) ||
|
||||||
|
(log.image_filename && log.image_filename.toLowerCase().includes(term)) ||
|
||||||
|
(log.device_model && log.device_model.toLowerCase().includes(term)) ||
|
||||||
|
(log.weapon && log.weapon.toLowerCase().includes(term)) ||
|
||||||
|
(log.target_type && log.target_type.toLowerCase().includes(term)) ||
|
||||||
|
(log.target_status && log.target_status.toLowerCase().includes(term)) ||
|
||||||
|
(log.ip_address && log.ip_address.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [logs, searchTerm, statusFilter, bannedWallets]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
|
||||||
|
<ScrollText size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Journal & Contrôle des Uploads</h2>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
Historique des transferts, diagnostic OpenCV (détection cibles) et modération des wallets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={fetchLogs}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300 hover:text-white rounded-xl text-sm font-semibold transition-all disabled:opacity-50"
|
||||||
|
title="Rafraîchir les logs"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? "animate-spin text-indigo-400" : ""} />
|
||||||
|
<span>Actualiser</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`${API_BASE_URL}/api/logs/export?format=csv`}
|
||||||
|
download
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-semibold transition-all shadow-lg shadow-indigo-600/20 hover:scale-105 active:scale-95"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
<span>Exporter CSV</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{!showClearConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowClearConfirm(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 bg-slate-900 border border-slate-800 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 rounded-xl text-sm transition-all"
|
||||||
|
title="Purger tous les logs"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 p-1 bg-rose-500/10 border border-rose-500/30 rounded-xl animate-in fade-in">
|
||||||
|
<span className="text-xs text-rose-400 px-2 font-semibold">Effacer tout ?</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="px-2.5 py-1 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
Oui
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowClearConfirm(false)}
|
||||||
|
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs transition-all"
|
||||||
|
>
|
||||||
|
Non
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Total Uploads</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-2xl font-bold">{stats?.total_uploads ?? logs.length}</p>
|
||||||
|
{stats && stats.total_uploads > 0 && (
|
||||||
|
<span className="text-xs font-semibold text-emerald-400">
|
||||||
|
{Math.round(((stats.success_count || 0) / stats.total_uploads) * 100)}% succès
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
|
||||||
|
<ScrollText size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Aujourd'hui</p>
|
||||||
|
<p className="text-2xl font-bold text-indigo-400">{stats?.today_uploads ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
|
||||||
|
<Calendar size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallets Bannis</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-2xl font-bold text-rose-400">{bannedWallets.length}</p>
|
||||||
|
<span className="text-xs text-slate-500 font-mono">/ {stats?.unique_wallets ?? 0} total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-xl flex items-center justify-center">
|
||||||
|
<Ban size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Volume Transféré</p>
|
||||||
|
<p className="text-2xl font-bold text-emerald-400">
|
||||||
|
{formatFileSize(stats?.total_bytes || logs.reduce((acc, l) => acc + (l.file_size || 0), 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-xl flex items-center justify-center">
|
||||||
|
<HardDrive size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters & Search Toolbar */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative w-full md:w-96">
|
||||||
|
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Rechercher par Wallet, Session, Cible, Image..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-xl pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status & OpenCV Filter */}
|
||||||
|
<div className="flex items-center gap-2 w-full md:w-auto justify-start md:justify-end flex-wrap">
|
||||||
|
<span className="text-xs text-slate-500 font-semibold uppercase tracking-wider flex items-center gap-1.5 mr-1">
|
||||||
|
<Filter size={13} />
|
||||||
|
Filtre:
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex bg-slate-950 p-1 rounded-xl border border-slate-800 flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter("ALL")}
|
||||||
|
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||||
|
statusFilter === "ALL"
|
||||||
|
? "bg-slate-800 text-white shadow-sm"
|
||||||
|
: "text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tous ({logs.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter("SUCCESS")}
|
||||||
|
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||||
|
statusFilter === "SUCCESS"
|
||||||
|
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"
|
||||||
|
: "text-slate-400 hover:text-emerald-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Succès
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter("INVALID_TARGET")}
|
||||||
|
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||||
|
statusFilter === "INVALID_TARGET"
|
||||||
|
? "bg-amber-500/20 text-amber-300 border border-amber-500/30"
|
||||||
|
: "text-slate-400 hover:text-amber-400"
|
||||||
|
}`}
|
||||||
|
title="Photos non reconnues comme cible par OpenCV"
|
||||||
|
>
|
||||||
|
⚠ Cibles Douteuses
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter("BANNED")}
|
||||||
|
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||||
|
statusFilter === "BANNED"
|
||||||
|
? "bg-rose-500/20 text-rose-300 border border-rose-500/30"
|
||||||
|
: "text-slate-400 hover:text-rose-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Bannis ({bannedWallets.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Table */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl shadow-black/20">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-slate-950/60 border-b border-slate-800 text-slate-400 font-semibold text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3.5">Date & Heure</th>
|
||||||
|
<th className="px-4 py-3.5">Diagnostic OpenCV</th>
|
||||||
|
<th className="px-4 py-3.5">Wallet Contributeur</th>
|
||||||
|
<th className="px-4 py-3.5">Session / Cible</th>
|
||||||
|
<th className="px-4 py-3.5">Appareil</th>
|
||||||
|
<th className="px-4 py-3.5">Fichier</th>
|
||||||
|
<th className="px-4 py-3.5 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
|
{filteredLogs.map((log) => {
|
||||||
|
const dateObj = new Date(log.timestamp);
|
||||||
|
const isValidDate = !isNaN(dateObj.getTime());
|
||||||
|
const formattedDate = isValidDate
|
||||||
|
? dateObj.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })
|
||||||
|
: log.timestamp;
|
||||||
|
const formattedTime = isValidDate
|
||||||
|
? dateObj.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const banned = isWalletBanned(log.wallet_hash);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={log.id}
|
||||||
|
className="hover:bg-slate-800/40 transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Timestamp */}
|
||||||
|
<td className="px-5 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-slate-200">{formattedDate}</span>
|
||||||
|
<span className="text-xs text-slate-500 font-mono">{formattedTime}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* OpenCV Diagnosis & Upload Status */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{log.status === "FAILED" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.error_message || "Échec upload"}>
|
||||||
|
<XCircle size={12} />
|
||||||
|
UPLOAD ÉCHEC
|
||||||
|
</span>
|
||||||
|
) : log.target_status === "VALID" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 max-w-fit" title={log.target_details || "Cible certifiée"}>
|
||||||
|
<ShieldCheck size={12} />
|
||||||
|
CIBLE CERTIFIÉE ({Math.round((log.target_confidence || 1) * 100)}%)
|
||||||
|
</span>
|
||||||
|
) : log.target_status === "SUSPICIOUS" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20 max-w-fit" title={log.target_details || "1 seul anneau détecté"}>
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
DOUTEUSE (1 ANNEAU)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.target_details || "Aucun motif de cible"}>
|
||||||
|
<ShieldAlert size={12} />
|
||||||
|
NON RECONNUE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.target_rings_count ? (
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">
|
||||||
|
{log.target_rings_count} anneaux concentriques
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Wallet Hash & Ban Badge */}
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{log.wallet_hash ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
onClick={() => setSearchTerm(log.wallet_hash || "")}
|
||||||
|
className="font-mono text-xs text-indigo-300 hover:text-indigo-200 bg-indigo-500/5 px-2 py-1 rounded border border-indigo-500/10 hover:border-indigo-500/30 cursor-pointer transition-colors"
|
||||||
|
title="Cliquer pour filtrer par ce wallet"
|
||||||
|
>
|
||||||
|
{log.wallet_hash.length > 16
|
||||||
|
? `${log.wallet_hash.substring(0, 8)}...${log.wallet_hash.substring(log.wallet_hash.length - 8)}`
|
||||||
|
: log.wallet_hash}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(log.wallet_hash!)}
|
||||||
|
className="text-slate-500 hover:text-slate-300 transition-colors p-1"
|
||||||
|
title="Copier le hash du wallet"
|
||||||
|
>
|
||||||
|
{copiedWallet === log.wallet_hash ? (
|
||||||
|
<Check size={14} className="text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banned && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20 max-w-fit">
|
||||||
|
<Ban size={10} />
|
||||||
|
WALLET BANNI
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500 italic">Anonyme / Non fourni</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Session & Target */}
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex flex-col gap-1 max-w-[180px]">
|
||||||
|
{log.session_id && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-300 font-mono truncate">
|
||||||
|
<span className="text-slate-500">ID:</span> {log.session_id}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center flex-wrap gap-1.5">
|
||||||
|
{log.target_type && (
|
||||||
|
<span className="text-[10px] uppercase font-bold bg-slate-800 text-slate-300 px-1.5 py-0.5 rounded border border-slate-700">
|
||||||
|
{log.target_type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.weapon && (
|
||||||
|
<span className="text-[10px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded">
|
||||||
|
{log.weapon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.impacts_count > 0 && (
|
||||||
|
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 flex items-center gap-0.5">
|
||||||
|
<Crosshair size={10} />
|
||||||
|
{log.impacts_count} imp.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Device & IP */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||||
|
<Smartphone size={13} className="text-indigo-400" />
|
||||||
|
<span>{log.device_model || log.device_os || "Inconnu"}</span>
|
||||||
|
</div>
|
||||||
|
{log.ip_address && (
|
||||||
|
<span className="text-[11px] text-slate-500 font-mono">
|
||||||
|
{log.ip_address.replace("::ffff:", "")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* File & Size */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{log.image_filename ? (
|
||||||
|
<Link
|
||||||
|
href={`/photo/${log.image_filename}`}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 font-mono flex items-center gap-1 group/link truncate max-w-[130px]"
|
||||||
|
title={log.image_filename}
|
||||||
|
>
|
||||||
|
<span className="truncate">{log.image_filename}</span>
|
||||||
|
<ExternalLink size={12} className="opacity-0 group-hover/link:opacity-100 transition-opacity" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500 italic">Aucun fichier</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-slate-500 font-mono">
|
||||||
|
{formatFileSize(log.file_size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="px-4 py-4 text-right whitespace-nowrap">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{/* Ban / Unban Button */}
|
||||||
|
{log.wallet_hash && (
|
||||||
|
banned ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnbanWallet(log.wallet_hash!)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-emerald-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
title="Débannir ce wallet"
|
||||||
|
>
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setBanModalWallet(log.wallet_hash)}
|
||||||
|
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
title="Bannir définitivement ce wallet"
|
||||||
|
>
|
||||||
|
<Ban size={16} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedLog(log)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-indigo-300 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
title="Voir les détails complets"
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLog(log.id)}
|
||||||
|
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
title="Supprimer ce log"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{filteredLogs.length === 0 && (
|
||||||
|
<div className="py-20 flex flex-col items-center justify-center text-center px-4">
|
||||||
|
<div className="p-4 bg-slate-950 border border-slate-800 rounded-2xl text-slate-600 mb-3">
|
||||||
|
<ScrollText size={36} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-slate-300 font-bold text-base">Aucun log trouvé</h3>
|
||||||
|
<p className="text-slate-500 text-xs mt-1 max-w-sm">
|
||||||
|
{searchTerm || statusFilter !== "ALL"
|
||||||
|
? "Aucun enregistrement ne correspond à vos critères de recherche."
|
||||||
|
: "Les logs apparaîtront ici dès que des photos ou des sessions seront uploadées."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Modal */}
|
||||||
|
{selectedLog && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
|
||||||
|
<Info size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-100">Détails du Log #{selectedLog.id}</h3>
|
||||||
|
<p className="text-xs text-slate-400">{new Date(selectedLog.timestamp).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedLog(null)}
|
||||||
|
className="text-slate-400 hover:text-white p-2 hover:bg-slate-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-6 text-sm">
|
||||||
|
{/* OpenCV Diagnostic Banner */}
|
||||||
|
<div className={`p-4 rounded-xl border flex flex-col gap-2 ${
|
||||||
|
selectedLog.target_status === "VALID"
|
||||||
|
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-300"
|
||||||
|
: selectedLog.target_status === "SUSPICIOUS"
|
||||||
|
? "bg-amber-500/10 border-amber-500/20 text-amber-300"
|
||||||
|
: "bg-rose-500/10 border-rose-500/20 text-rose-300"
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 font-bold">
|
||||||
|
{selectedLog.target_status === "VALID" ? <ShieldCheck size={18} /> : <AlertTriangle size={18} />}
|
||||||
|
Diagnostic OpenCV : {selectedLog.target_status || "VALID"}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded bg-black/30">
|
||||||
|
Confiance : {Math.round((selectedLog.target_confidence || 1) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs opacity-90">{selectedLog.target_details || "Cible détectée avec succès."}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* General Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallet Hash</span>
|
||||||
|
<div className="font-mono text-xs text-indigo-300 break-all select-all">
|
||||||
|
{selectedLog.wallet_hash || "Non spécifié"}
|
||||||
|
</div>
|
||||||
|
{isWalletBanned(selectedLog.wallet_hash) && (
|
||||||
|
<span className="inline-block mt-1 text-[10px] text-rose-400 font-bold bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20">
|
||||||
|
Banni ({getBannedInfo(selectedLog.wallet_hash)?.reason})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Session ID</span>
|
||||||
|
<div className="font-mono text-xs text-slate-300 break-all select-all">
|
||||||
|
{selectedLog.session_id || "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Appareil</span>
|
||||||
|
<div className="text-xs text-slate-300">
|
||||||
|
{selectedLog.device_model || "Inconnu"} ({selectedLog.device_os || "OS inconnu"})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Adresse IP</span>
|
||||||
|
<div className="font-mono text-xs text-slate-300">
|
||||||
|
{selectedLog.ip_address || "Inconnue"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Cible & Arme</span>
|
||||||
|
<div className="text-xs text-slate-300">
|
||||||
|
Type: <span className="font-bold text-white">{selectedLog.target_type || "N/A"}</span> | Arme: {selectedLog.weapon || "N/A"} | {selectedLog.distance_meters ? `${selectedLog.distance_meters}m` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Fichiers & Taille</span>
|
||||||
|
<div className="text-xs text-slate-300">
|
||||||
|
Image: <span className="font-mono">{selectedLog.image_filename || "N/A"}</span> ({formatFileSize(selectedLog.file_size)})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw Metadata JSON */}
|
||||||
|
{selectedLog.raw_metadata && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Données Brutes (JSON)</span>
|
||||||
|
<pre className="bg-slate-950 border border-slate-800 p-4 rounded-xl text-xs font-mono text-slate-300 overflow-x-auto max-h-48">
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(selectedLog.raw_metadata), null, 2);
|
||||||
|
} catch {
|
||||||
|
return selectedLog.raw_metadata;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="p-4 border-t border-slate-800 bg-slate-950/50 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedLog.image_filename && (
|
||||||
|
<Link
|
||||||
|
href={`/photo/${selectedLog.image_filename}`}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
Ouvrir la session
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{selectedLog.wallet_hash && (
|
||||||
|
isWalletBanned(selectedLog.wallet_hash) ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnbanWallet(selectedLog.wallet_hash!)}
|
||||||
|
className="px-3 py-2 bg-slate-800 hover:bg-emerald-600/30 text-emerald-400 rounded-xl text-xs font-bold transition-all border border-emerald-500/20"
|
||||||
|
>
|
||||||
|
Débannir ce wallet
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLog(null);
|
||||||
|
setBanModalWallet(selectedLog.wallet_hash);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20"
|
||||||
|
>
|
||||||
|
Bannir ce wallet
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedLog(null)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ban Confirmation Modal */}
|
||||||
|
{banModalWallet && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 space-y-6 shadow-2xl animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400">
|
||||||
|
<Ban size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-slate-100">Bannir un Wallet</h3>
|
||||||
|
<p className="text-xs text-slate-400">Bloquer définitivement tout upload futur de ce compte.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 font-mono text-xs text-indigo-300 break-all">
|
||||||
|
{banModalWallet}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||||
|
Motif du bannissement
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={banReason}
|
||||||
|
onChange={(e) => setBanReason(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 focus:border-rose-500 rounded-xl px-3 py-2 text-sm text-slate-200 outline-none"
|
||||||
|
>
|
||||||
|
<option value="Images non conformes / Fausse cible">Images non conformes / Fausse cible</option>
|
||||||
|
<option value="Spam / Uploads abusifs répétés">Spam / Uploads abusifs répétés</option>
|
||||||
|
<option value="Contenu inapproprié ou illicite">Contenu inapproprié ou illicite</option>
|
||||||
|
<option value="Tentative de manipulation des données">Tentative de manipulation des données</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setBanModalWallet(null)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBanWallet}
|
||||||
|
disabled={isSubmittingBan}
|
||||||
|
className="px-4 py-2 bg-rose-600 hover:bg-rose-500 text-white rounded-xl text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Ban size={14} />
|
||||||
|
{isSubmittingBan ? "Bannissement..." : "Confirmer le Bannissement"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { fetchApi } from "@/lib/api";
|
||||||
|
import LogsManager from "./LogsManager";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Logs d'Uploads | Bully IA Dashboard",
|
||||||
|
description: "Journal complet des téléversements de sessions et métadonnées",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LogsPage() {
|
||||||
|
let logs = [];
|
||||||
|
let stats = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [logsRes, statsRes] = await Promise.all([
|
||||||
|
fetchApi('/api/logs?limit=100'),
|
||||||
|
fetchApi('/api/logs/stats')
|
||||||
|
]);
|
||||||
|
logs = logsRes.logs || [];
|
||||||
|
stats = statsRes.stats || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur chargement initial des logs:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogsManager
|
||||||
|
initialLogs={logs}
|
||||||
|
initialStats={stats}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { fetchApi } from "@/lib/api";
|
||||||
|
import DatasetToolbar from "@/components/DatasetToolbar";
|
||||||
|
import { Calendar, User, Crosshair } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface PlottingData {
|
||||||
|
session_id: string;
|
||||||
|
wallet_hash: string;
|
||||||
|
timestamp: string;
|
||||||
|
plotting: {
|
||||||
|
impacts: any[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoData {
|
||||||
|
filename: string;
|
||||||
|
imageUrl: string;
|
||||||
|
data: PlottingData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
let photos: PhotoData[] = [];
|
||||||
|
try {
|
||||||
|
const response = await fetchApi('/api/all-data');
|
||||||
|
photos = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching photos:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Galerie des Sessions</h2>
|
||||||
|
<p className="text-slate-400">Visualisez et gérez les données d'entraînement collectées.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatasetToolbar />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{photos.map((photo) => (
|
||||||
|
<Link
|
||||||
|
key={photo.filename}
|
||||||
|
href={`/photo/${photo.filename}`}
|
||||||
|
className="group relative bg-slate-900 rounded-xl border border-slate-800 overflow-hidden hover:border-indigo-500/50 transition-all hover:shadow-2xl hover:shadow-indigo-500/10"
|
||||||
|
>
|
||||||
|
{/* Image Preview */}
|
||||||
|
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={photo.imageUrl}
|
||||||
|
alt={photo.filename}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
|
||||||
|
|
||||||
|
{/* Badge impacts */}
|
||||||
|
{photo.data?.plotting?.impacts && (
|
||||||
|
<div className="absolute top-3 right-3 bg-indigo-600/90 backdrop-blur-md px-2 py-1 rounded-md text-[10px] font-bold flex items-center gap-1">
|
||||||
|
<Crosshair size={12} />
|
||||||
|
{photo.data.plotting.impacts.length} IMPACTS
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<Calendar size={14} className="text-indigo-400" />
|
||||||
|
{photo.data ? new Date(photo.data.timestamp).toLocaleDateString() : 'Date inconnue'}
|
||||||
|
</div>
|
||||||
|
{photo.data ? (
|
||||||
|
<span className="text-[10px] bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20 font-bold">DATA OK</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] bg-rose-500/10 text-rose-400 px-1.5 py-0.5 rounded border border-rose-500/20 font-bold">NO DATA</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-300 font-mono truncate">
|
||||||
|
<User size={14} className="text-indigo-400" />
|
||||||
|
{photo.data?.wallet_hash?.substring(0, 12) || 'Anonyme'}...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-slate-800 flex justify-between items-center">
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Session ID</span>
|
||||||
|
<span className="text-xs text-slate-300 font-mono truncate max-w-[120px]">
|
||||||
|
{photo.data?.session_id || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{photos.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-slate-500 gap-4 border-2 border-dashed border-slate-800 rounded-3xl">
|
||||||
|
<Crosshair size={48} className="opacity-20" />
|
||||||
|
<p>Aucune session d'entraînement trouvée.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { fetchApi } from "@/lib/api";
|
||||||
|
import PhotoEditor from "@/components/PhotoEditor";
|
||||||
|
import { ChevronLeft, Download } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function PhotoDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id: encodedId } = await params;
|
||||||
|
const id = decodeURIComponent(encodedId);
|
||||||
|
let photo = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchApi('/api/all-data');
|
||||||
|
photo = response.data.find((p: any) => p.filename === id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching photo detail:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return <div className="p-12 text-center">Session non trouvée.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
Retour à la galerie
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<a
|
||||||
|
href={`/uploads/images/${id}`}
|
||||||
|
download
|
||||||
|
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
Image Originale
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PhotoEditor initialPhoto={photo} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Download, Archive, AlertTriangle, Check, RefreshCw } from "lucide-react";
|
||||||
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function DatasetToolbar() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [isArchiving, setIsArchiving] = useState(false);
|
||||||
|
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
// On ouvre simplement l'URL dans un nouvel onglet pour déclencher le téléchargement
|
||||||
|
window.open(`${API_BASE_URL}/api/export/zip`, '_blank');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
setIsArchiving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/export/archive`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setShowArchiveConfirm(false);
|
||||||
|
router.refresh(); // Rafraîchir la galerie
|
||||||
|
} else {
|
||||||
|
alert("Erreur lors de l'archivage");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Archive error:", error);
|
||||||
|
alert("Erreur réseau");
|
||||||
|
} finally {
|
||||||
|
setIsArchiving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between gap-6 shadow-xl shadow-indigo-500/5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-bold text-lg flex items-center gap-2">
|
||||||
|
<RefreshCw size={20} className="text-indigo-400" />
|
||||||
|
Gestion du Dataset
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500">Exportez vos sessions étiquetées en ZIP et archivez-les pour nettoyer la galerie.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 px-5 py-2.5 rounded-xl text-sm font-bold transition-all hover:scale-105 active:scale-95 shadow-lg shadow-indigo-600/20"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
{isExporting ? "Préparation..." : "Télécharger ZIP"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!showArchiveConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchiveConfirm(true)}
|
||||||
|
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-5 py-2.5 rounded-xl text-sm font-bold transition-all border border-slate-700"
|
||||||
|
>
|
||||||
|
<Archive size={18} />
|
||||||
|
Archiver les sessions
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 text-xs font-bold">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
Vider la galerie ?
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleArchive}
|
||||||
|
disabled={isArchiving}
|
||||||
|
className="bg-rose-600 hover:bg-rose-500 p-2.5 rounded-xl transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check size={18} className="text-white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchiveConfirm(false)}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 p-2.5 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Archive size={18} className="text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import PhotoOverlay from "./PhotoOverlay";
|
||||||
|
import { Info, Calendar, User, Smartphone, Cpu, Save, Edit2, X, Trash2, Check, Tag } from "lucide-react";
|
||||||
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface PhotoEditorProps {
|
||||||
|
initialPhoto: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREDEFINED_LABELS = ["bullet_hole", "tear", "miss", "rebound", "other"];
|
||||||
|
|
||||||
|
export default function PhotoEditor({ initialPhoto }: PhotoEditorProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [photoData, setPhotoData] = useState(initialPhoto.data);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleImpactsChange = (newImpacts: any[]) => {
|
||||||
|
setPhotoData({
|
||||||
|
...photoData,
|
||||||
|
plotting: {
|
||||||
|
...photoData.plotting,
|
||||||
|
impacts: newImpacts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLabel = (index: number, label: string) => {
|
||||||
|
const newImpacts = [...photoData.plotting.impacts];
|
||||||
|
newImpacts[index] = { ...newImpacts[index], label };
|
||||||
|
handleImpactsChange(newImpacts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateScore = (index: number, score: number) => {
|
||||||
|
const newImpacts = [...photoData.plotting.impacts];
|
||||||
|
newImpacts[index] = { ...newImpacts[index], score };
|
||||||
|
handleImpactsChange(newImpacts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/data/${initialPhoto.filename}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(photoData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setIsEditing(false);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
alert("Erreur lors de la sauvegarde");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Save error:", error);
|
||||||
|
alert("Erreur réseau");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const impacts = photoData?.plotting?.impacts || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
{/* Main View */}
|
||||||
|
<div className="xl:col-span-2 space-y-4">
|
||||||
|
<div className="flex items-center justify-between bg-slate-900/50 p-4 rounded-xl border border-slate-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${isEditing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'}`} />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{isEditing ? "Mode Édition Actif" : "Mode Visualisation"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPhotoData(initialPhoto.data);
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save size={18} />
|
||||||
|
{isSaving ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
Modifier les points
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PhotoOverlay
|
||||||
|
imageUrl={initialPhoto.imageUrl}
|
||||||
|
impacts={impacts}
|
||||||
|
targetCorners={photoData?.plotting?.target_corners || []}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onImpactsChange={handleImpactsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Info & Labeling */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Labeling Section (Only if editing or has impacts) */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Tag className="text-indigo-400" size={20} />
|
||||||
|
<h3 className="font-bold text-lg">Étiquetage (Impacts)</h3>
|
||||||
|
</div>
|
||||||
|
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2 py-0.5 rounded">
|
||||||
|
{impacts.length} TOTAL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 max-h-[400px] overflow-auto pr-2 custom-scrollbar">
|
||||||
|
{impacts.map((impact: any, index: number) => (
|
||||||
|
<div key={impact.id} className="bg-slate-950 p-3 rounded-xl border border-slate-800 space-y-3 group">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 uppercase">Impact #{impact.id}</span>
|
||||||
|
{isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
const newImpacts = impacts.filter((_: any, i: number) => i !== index);
|
||||||
|
handleImpactsChange(newImpacts);
|
||||||
|
}}
|
||||||
|
className="text-slate-600 hover:text-rose-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] text-slate-500 font-bold uppercase">Label</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<select
|
||||||
|
value={impact.label}
|
||||||
|
onChange={(e) => updateLabel(index, e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
{PREDEFINED_LABELS.map(l => <option key={l} value={l}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-indigo-300 font-medium">{impact.label}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] text-slate-500 font-bold uppercase">Score</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
value={impact.score}
|
||||||
|
onChange={(e) => updateScore(index, parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-emerald-400 font-bold">{impact.score}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{impacts.length === 0 && (
|
||||||
|
<p className="text-center py-8 text-xs text-slate-500 italic">
|
||||||
|
Aucun impact détecté. {isEditing ? "Cliquez sur l'image pour en ajouter." : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata Card (Read Only) */}
|
||||||
|
<div className="bg-slate-900/50 border border-slate-800 rounded-2xl p-6 space-y-4 opacity-80">
|
||||||
|
<div className="flex items-center gap-3 pb-2 border-b border-slate-800">
|
||||||
|
<Info className="text-slate-400" size={18} />
|
||||||
|
<h3 className="font-bold text-sm text-slate-300">Métadonnées Session</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InfoItem icon={<Calendar size={14} />} label="Date" value={photoData ? new Date(photoData.timestamp).toLocaleString() : 'Inconnue'} />
|
||||||
|
<InfoItem icon={<User size={14} />} label="Contributeur" value={photoData?.wallet_hash || 'Anonyme'} isMono />
|
||||||
|
<InfoItem icon={<Smartphone size={14} />} label="Appareil" value={photoData?.device_info?.model || 'Inconnu'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ icon, label, value, isMono = false }: any) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 font-bold uppercase">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs text-slate-300 ${isMono ? 'font-mono truncate' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
norm_x: number;
|
||||||
|
norm_y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Impact {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
score: number;
|
||||||
|
coords: Point;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoOverlayProps {
|
||||||
|
imageUrl: string;
|
||||||
|
impacts: Impact[];
|
||||||
|
targetCorners: Point[];
|
||||||
|
isEditing?: boolean;
|
||||||
|
onImpactsChange?: (impacts: Impact[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhotoOverlay({
|
||||||
|
imageUrl,
|
||||||
|
impacts,
|
||||||
|
targetCorners,
|
||||||
|
isEditing = false,
|
||||||
|
onImpactsChange
|
||||||
|
}: PhotoOverlayProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateDimensions = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setDimensions({
|
||||||
|
width: containerRef.current.offsetWidth,
|
||||||
|
height: containerRef.current.offsetHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
window.addEventListener("resize", updateDimensions);
|
||||||
|
return () => window.removeEventListener("resize", updateDimensions);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImageClick = (e: React.MouseEvent) => {
|
||||||
|
if (!isEditing || !onImpactsChange || dragIndex !== null) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
// Calculer les coordonnées normalisées
|
||||||
|
const x = (e.clientX - rect.left) / dimensions.width;
|
||||||
|
const y = (e.clientY - rect.top) / dimensions.height;
|
||||||
|
|
||||||
|
// Créer un nouvel impact
|
||||||
|
const newImpact: Impact = {
|
||||||
|
id: impacts.length > 0 ? Math.max(...impacts.map(i => i.id)) + 1 : 1,
|
||||||
|
label: "bullet_hole",
|
||||||
|
score: 10,
|
||||||
|
coords: { norm_x: x, norm_y: y }
|
||||||
|
};
|
||||||
|
|
||||||
|
onImpactsChange([...impacts, newImpact]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (dragIndex === null || !onImpactsChange) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const x = (e.clientX - rect.left) / dimensions.width;
|
||||||
|
const y = (e.clientY - rect.top) / dimensions.height;
|
||||||
|
|
||||||
|
const newImpacts = [...impacts];
|
||||||
|
newImpacts[dragIndex] = {
|
||||||
|
...newImpacts[dragIndex],
|
||||||
|
coords: { norm_x: x, norm_y: y }
|
||||||
|
};
|
||||||
|
|
||||||
|
onImpactsChange(newImpacts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteImpact = (e: React.MouseEvent, index: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isEditing || !onImpactsChange) return;
|
||||||
|
const newImpacts = impacts.filter((_, i) => i !== index);
|
||||||
|
onImpactsChange(newImpacts);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative w-full flex items-center justify-center bg-slate-900 rounded-2xl overflow-hidden border border-slate-800 shadow-2xl min-h-[400px] ${isEditing ? 'cursor-crosshair' : ''}`}
|
||||||
|
onClick={handleImageClick}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={() => setDragIndex(null)}
|
||||||
|
onMouseLeave={() => setDragIndex(null)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Target Analysis"
|
||||||
|
className="max-w-full max-h-[80vh] object-contain select-none"
|
||||||
|
draggable={false}
|
||||||
|
onLoad={(e) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
setDimensions({ width: img.clientWidth, height: img.clientHeight });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{dimensions.width > 0 && (
|
||||||
|
<svg
|
||||||
|
className="absolute pointer-events-none"
|
||||||
|
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||||
|
style={{ width: dimensions.width, height: dimensions.height }}
|
||||||
|
>
|
||||||
|
{/* Draw Target Box if corners exist */}
|
||||||
|
{targetCorners && targetCorners.length === 4 && (
|
||||||
|
<polygon
|
||||||
|
points={targetCorners.map(c => `${c.norm_x * dimensions.width},${c.norm_y * dimensions.height}`).join(' ')}
|
||||||
|
fill="rgba(79, 70, 229, 0.15)"
|
||||||
|
stroke="#6366f1"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4 2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Draw Impacts */}
|
||||||
|
{impacts.map((impact, index) => (
|
||||||
|
<g key={impact.id}>
|
||||||
|
{/* Glow effect */}
|
||||||
|
<circle
|
||||||
|
cx={impact.coords.norm_x * dimensions.width}
|
||||||
|
cy={impact.coords.norm_y * dimensions.height}
|
||||||
|
r={isEditing ? "12" : "8"}
|
||||||
|
fill={isEditing ? "rgba(244, 63, 94, 0.2)" : "rgba(244, 63, 94, 0.3)"}
|
||||||
|
className={isEditing ? 'pointer-events-auto cursor-move' : ''}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (!isEditing) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragIndex(index);
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => deleteImpact(e, index)}
|
||||||
|
/>
|
||||||
|
{/* Main circle */}
|
||||||
|
<circle
|
||||||
|
cx={impact.coords.norm_x * dimensions.width}
|
||||||
|
cy={impact.coords.norm_y * dimensions.height}
|
||||||
|
r="5"
|
||||||
|
fill="#f43f5e"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
{/* Score text */}
|
||||||
|
<text
|
||||||
|
x={impact.coords.norm_x * dimensions.width}
|
||||||
|
y={impact.coords.norm_y * dimensions.height - 10}
|
||||||
|
className="text-[12px] font-bold fill-white text-center select-none"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ textShadow: '0 0 4px rgba(0,0,0,0.9), 1px 1px 0 rgba(0,0,0,0.9)' }}
|
||||||
|
>
|
||||||
|
{impact.score}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div className="absolute bottom-4 right-4 bg-slate-950/80 backdrop-blur-md px-3 py-1.5 rounded-full text-[10px] font-bold text-indigo-400 border border-indigo-500/30">
|
||||||
|
MODE ÉDITION : CLIC POUR AJOUTER • DRAG POUR DÉPLACER • CLIC DROIT POUR SUPPRIMER
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const isServer = typeof window === 'undefined';
|
||||||
|
export const API_BASE_URL = isServer
|
||||||
|
? `http://127.0.0.1:${process.env.PORT || 3000}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
export async function fetchApi(endpoint: string) {
|
||||||
|
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${endpoint}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Keep directory structure
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "backendia",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dashboard": "cd dashboard && npm run dev -- -p 3001",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@techstark/opencv-js": "^5.0.0-release.1",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"next": "^16.2.4",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"sqlite3": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,857 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const AdmZip = require('adm-zip');
|
||||||
|
|
||||||
|
|
||||||
|
const TargetValidator = require('./services/target_validator');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Arborescence demandée
|
||||||
|
const uploadDir = path.join(__dirname, 'uploads');
|
||||||
|
const imagesDir = path.join(uploadDir, 'images');
|
||||||
|
const dataDir = path.join(uploadDir, 'data');
|
||||||
|
const exportsDir = path.join(__dirname, 'exports');
|
||||||
|
|
||||||
|
// S'assurer que les dossiers existent
|
||||||
|
[uploadDir, imagesDir, dataDir, exportsDir].forEach(dir => {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialisation de la base de données SQLite
|
||||||
|
const dbPath = path.join(dataDir, 'database.sqlite');
|
||||||
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Erreur de connexion à SQLite:', err.message);
|
||||||
|
} else {
|
||||||
|
console.log('Connecté à la base de données SQLite.');
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
|
||||||
|
wallet_hash TEXT PRIMARY KEY,
|
||||||
|
photo_count INTEGER DEFAULT 0,
|
||||||
|
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS banned_wallets (
|
||||||
|
wallet_hash TEXT PRIMARY KEY,
|
||||||
|
reason TEXT,
|
||||||
|
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
banned_by TEXT DEFAULT 'Admin'
|
||||||
|
)`);
|
||||||
|
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS upload_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
session_id TEXT,
|
||||||
|
wallet_hash TEXT,
|
||||||
|
image_filename TEXT,
|
||||||
|
json_filename TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
device_model TEXT,
|
||||||
|
device_os TEXT,
|
||||||
|
target_type TEXT,
|
||||||
|
weapon TEXT,
|
||||||
|
distance_meters INTEGER,
|
||||||
|
impacts_count INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'SUCCESS',
|
||||||
|
target_valid INTEGER DEFAULT 1,
|
||||||
|
target_status TEXT DEFAULT 'VALID',
|
||||||
|
target_confidence REAL DEFAULT 1.0,
|
||||||
|
target_rings_count INTEGER DEFAULT 0,
|
||||||
|
target_details TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
raw_metadata TEXT
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// Migration safe des colonnes OpenCV si la table existait déjà
|
||||||
|
const migrations = [
|
||||||
|
"ALTER TABLE upload_logs ADD COLUMN target_valid INTEGER DEFAULT 1",
|
||||||
|
"ALTER TABLE upload_logs ADD COLUMN target_status TEXT DEFAULT 'VALID'",
|
||||||
|
"ALTER TABLE upload_logs ADD COLUMN target_confidence REAL DEFAULT 1.0",
|
||||||
|
"ALTER TABLE upload_logs ADD COLUMN target_rings_count INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE upload_logs ADD COLUMN target_details TEXT"
|
||||||
|
];
|
||||||
|
migrations.forEach(sql => {
|
||||||
|
db.run(sql, () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_wallet ON upload_logs(wallet_hash)`);
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_timestamp ON upload_logs(timestamp)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper pour insérer un log d'upload
|
||||||
|
function logUploadEntry({
|
||||||
|
sessionId = null,
|
||||||
|
walletHash = null,
|
||||||
|
imageFilename = null,
|
||||||
|
jsonFilename = null,
|
||||||
|
fileSize = null,
|
||||||
|
ipAddress = null,
|
||||||
|
userAgent = null,
|
||||||
|
deviceModel = null,
|
||||||
|
deviceOs = null,
|
||||||
|
targetType = null,
|
||||||
|
weapon = null,
|
||||||
|
distanceMeters = null,
|
||||||
|
impactsCount = 0,
|
||||||
|
status = 'SUCCESS',
|
||||||
|
targetValid = 1,
|
||||||
|
targetStatus = 'VALID',
|
||||||
|
targetConfidence = 1.0,
|
||||||
|
targetRingsCount = 0,
|
||||||
|
targetDetails = null,
|
||||||
|
errorMessage = null,
|
||||||
|
rawMetadata = null
|
||||||
|
}) {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO upload_logs (
|
||||||
|
session_id, wallet_hash, image_filename, json_filename, file_size,
|
||||||
|
ip_address, user_agent, device_model, device_os, target_type,
|
||||||
|
weapon, distance_meters, impacts_count, status,
|
||||||
|
target_valid, target_status, target_confidence, target_rings_count, target_details,
|
||||||
|
error_message, raw_metadata
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
db.run(query, [
|
||||||
|
sessionId,
|
||||||
|
walletHash,
|
||||||
|
imageFilename,
|
||||||
|
jsonFilename,
|
||||||
|
fileSize,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
deviceModel,
|
||||||
|
deviceOs,
|
||||||
|
targetType,
|
||||||
|
weapon,
|
||||||
|
distanceMeters,
|
||||||
|
impactsCount,
|
||||||
|
status,
|
||||||
|
targetValid ? 1 : 0,
|
||||||
|
targetStatus,
|
||||||
|
targetConfidence,
|
||||||
|
targetRingsCount,
|
||||||
|
targetDetails,
|
||||||
|
errorMessage,
|
||||||
|
rawMetadata ? JSON.stringify(rawMetadata) : null
|
||||||
|
], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur lors de l'insertion dans upload_logs:", err.message);
|
||||||
|
} else {
|
||||||
|
console.log(`[LOG] Upload enregistré (ID: ${this.lastID}) - Statut: ${status} - OpenCV: ${targetStatus}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration de multer pour le stockage des fichiers
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: function (req, file, cb) {
|
||||||
|
cb(null, imagesDir); // Les photos vont dans uploads/images/
|
||||||
|
},
|
||||||
|
filename: function (req, file, cb) {
|
||||||
|
// Créer un nom de fichier unique tout en gardant l'extension originale
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: 15 * 1024 * 1024 // Limite de taille: 15 MB
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
|
||||||
|
// Accepter si c'est un mimetype image OU si l'extension est celle d'une image
|
||||||
|
if (file.mimetype.startsWith('image/') || allowedExts.includes(ext) || file.mimetype === 'application/octet-stream') {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
console.error(`Fichier rejeté: mimetype=${file.mimetype}, extension=${ext}`);
|
||||||
|
cb(new Error('Seules les images sont autorisées !'), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes de base
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', message: 'Le serveur backend IA fonctionne' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour l'upload de photo + données JSON
|
||||||
|
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
|
||||||
|
app.post('/api/upload', upload.single('photo'), async (req, res) => {
|
||||||
|
const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || '';
|
||||||
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
logUploadEntry({
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: 'Aucune photo fournie'
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
code: 'MISSING_PHOTO',
|
||||||
|
error: 'Aucune photo fournie dans la requête'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let plottingData = {};
|
||||||
|
if (req.body.plotting) {
|
||||||
|
try {
|
||||||
|
plottingData = JSON.parse(req.body.plotting);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur parsing JSON:", e);
|
||||||
|
logUploadEntry({
|
||||||
|
imageFilename: req.file.filename,
|
||||||
|
fileSize: req.file.size,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: 'Le champ plotting doit être un JSON valide'
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
code: 'INVALID_PLOTTING_JSON',
|
||||||
|
error: 'Le champ plotting contient un format JSON invalide'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const walletHash = plottingData.wallet_hash || null;
|
||||||
|
const sessionId = plottingData.session_id || null;
|
||||||
|
|
||||||
|
// 1. Vérification du bannissement de wallet
|
||||||
|
if (walletHash) {
|
||||||
|
const bannedEntry = await new Promise((resolve) => {
|
||||||
|
db.get('SELECT * FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, row) => {
|
||||||
|
resolve(row || null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bannedEntry) {
|
||||||
|
// Supprimer le fichier image temporaire pour ne pas consommer d'espace
|
||||||
|
if (req.file.path && fs.existsSync(req.file.path)) {
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch (e) {}
|
||||||
|
}
|
||||||
|
const banReason = bannedEntry.reason || 'Non-respect des règles de contribution / Image non conforme';
|
||||||
|
logUploadEntry({
|
||||||
|
sessionId,
|
||||||
|
walletHash,
|
||||||
|
imageFilename: req.file.filename,
|
||||||
|
fileSize: req.file.size,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: `Upload bloqué : wallet banni (${banReason})`
|
||||||
|
});
|
||||||
|
console.warn(`[MODÉRATION] Upload rejeté pour wallet banni: ${walletHash}`);
|
||||||
|
return res.status(403).json({
|
||||||
|
code: 'WALLET_BANNED',
|
||||||
|
error: 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
|
||||||
|
reason: banReason,
|
||||||
|
banned_at: bannedEntry.banned_at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validation de la cible par OpenCV
|
||||||
|
let validation = {
|
||||||
|
isValid: true,
|
||||||
|
status: 'VALID',
|
||||||
|
confidence: 1.0,
|
||||||
|
ringsCount: 0,
|
||||||
|
bestCenter: null,
|
||||||
|
details: 'Analyse non effectuée'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
validation = await TargetValidator.validateTarget(req.file.path);
|
||||||
|
console.log(`[OPENCV] Diagnostic cible pour ${req.file.filename}: ${validation.status} (${Math.round(validation.confidence * 100)}% conf, ${validation.ringsCount} anneaux)`);
|
||||||
|
} catch (cvErr) {
|
||||||
|
console.error("[OPENCV] Erreur analyse cible:", cvErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nom de base sans l'extension
|
||||||
|
const baseFilename = path.parse(req.file.filename).name;
|
||||||
|
const jsonFilename = `${baseFilename}.json`;
|
||||||
|
const jsonFilePath = path.join(dataDir, jsonFilename);
|
||||||
|
|
||||||
|
// Sauvegarde du JSON dans uploads/data/
|
||||||
|
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
|
||||||
|
|
||||||
|
// Extraction des métadonnées
|
||||||
|
const deviceInfo = plottingData.device_info || {};
|
||||||
|
const targetMeta = plottingData.target_metadata || {};
|
||||||
|
const impacts = plottingData.plotting?.impacts || [];
|
||||||
|
const impactsCount = Array.isArray(impacts) ? impacts.length : 0;
|
||||||
|
|
||||||
|
// Mise à jour de la BDD si on a un wallet_hash
|
||||||
|
if (walletHash) {
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
|
||||||
|
VALUES (?, 1, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(wallet_hash) DO UPDATE SET
|
||||||
|
photo_count = photo_count + 1,
|
||||||
|
last_upload = CURRENT_TIMESTAMP
|
||||||
|
`, [walletHash], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur lors de la mise à jour des stats :", err);
|
||||||
|
} else {
|
||||||
|
console.log(`Stats mises à jour pour le wallet_hash ${walletHash.substring(0, 8)}...`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enregistrement dans la table des logs d'upload avec diagnostic OpenCV
|
||||||
|
logUploadEntry({
|
||||||
|
sessionId,
|
||||||
|
walletHash,
|
||||||
|
imageFilename: req.file.filename,
|
||||||
|
jsonFilename,
|
||||||
|
fileSize: req.file.size,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
deviceModel: deviceInfo.model || null,
|
||||||
|
deviceOs: deviceInfo.os || null,
|
||||||
|
targetType: targetMeta.type || null,
|
||||||
|
weapon: targetMeta.weapon || null,
|
||||||
|
distanceMeters: targetMeta.distance_meters || null,
|
||||||
|
impactsCount,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
targetValid: validation.isValid ? 1 : 0,
|
||||||
|
targetStatus: validation.status,
|
||||||
|
targetConfidence: validation.confidence,
|
||||||
|
targetRingsCount: validation.ringsCount,
|
||||||
|
targetDetails: validation.details,
|
||||||
|
rawMetadata: plottingData
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Données reçues et sauvegardées:`);
|
||||||
|
console.log(`- Image: uploads/images/${req.file.filename}`);
|
||||||
|
console.log(`- JSON : uploads/data/${jsonFilename}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
code: 'UPLOAD_SUCCESS',
|
||||||
|
message: 'Photo et données uploadées avec succès',
|
||||||
|
file: {
|
||||||
|
filename: req.file.filename,
|
||||||
|
url: `/uploads/images/${req.file.filename}`,
|
||||||
|
size: req.file.size
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
filename: jsonFilename
|
||||||
|
},
|
||||||
|
target_validation: validation
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'upload:', error);
|
||||||
|
logUploadEntry({
|
||||||
|
imageFilename: req.file ? req.file.filename : null,
|
||||||
|
fileSize: req.file ? req.file.size : null,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: error.message || 'Erreur interne lors de l\'upload'
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
code: 'SERVER_ERROR',
|
||||||
|
error: error.message || 'Erreur interne du serveur lors de l\'upload'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes de Modération : Gestion des bannissements de wallets
|
||||||
|
app.get('/api/moderation/banned', (req, res) => {
|
||||||
|
db.all('SELECT * FROM banned_wallets ORDER BY banned_at DESC', [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur lecture wallets bannis:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur lecture wallets bannis' });
|
||||||
|
}
|
||||||
|
res.json({ banned: rows || [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/moderation/ban', (req, res) => {
|
||||||
|
const { wallet_hash, reason, banned_by } = req.body;
|
||||||
|
if (!wallet_hash) {
|
||||||
|
return res.status(400).json({ error: 'wallet_hash obligatoire' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO banned_wallets (wallet_hash, reason, banned_by, banned_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(wallet_hash) DO UPDATE SET
|
||||||
|
reason = excluded.reason,
|
||||||
|
banned_at = CURRENT_TIMESTAMP`,
|
||||||
|
[wallet_hash, reason || 'Contenu invalide ou non conforme aux règles', banned_by || 'Admin'],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur bannissement:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur lors du bannissement du wallet' });
|
||||||
|
}
|
||||||
|
console.log(`[MODÉRATION] Wallet ${wallet_hash} banni (Motif: ${reason})`);
|
||||||
|
res.json({ message: 'Wallet banni avec succès', wallet_hash });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/moderation/ban/:wallet_hash', (req, res) => {
|
||||||
|
const wallet_hash = req.params.wallet_hash;
|
||||||
|
db.run('DELETE FROM banned_wallets WHERE wallet_hash = ?', [wallet_hash], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur débannissement:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur lors du débannissement' });
|
||||||
|
}
|
||||||
|
console.log(`[MODÉRATION] Wallet ${wallet_hash} débanni`);
|
||||||
|
res.json({ message: 'Wallet débanni avec succès', wallet_hash });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour relancer l'analyse OpenCV sur une photo déjà existante
|
||||||
|
app.post('/api/moderation/verify/:filename', async (req, res) => {
|
||||||
|
const filename = req.params.filename;
|
||||||
|
const imgPath = path.join(imagesDir, filename);
|
||||||
|
if (!fs.existsSync(imgPath)) {
|
||||||
|
return res.status(404).json({ error: 'Image non trouvée' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await TargetValidator.validateTarget(imgPath);
|
||||||
|
res.json({ filename, validation: result });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour lister les logs avec filtres, recherche et pagination
|
||||||
|
app.get('/api/logs', (req, res) => {
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
|
||||||
|
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
||||||
|
const wallet = req.query.wallet || req.query.wallet_hash;
|
||||||
|
const status = req.query.status;
|
||||||
|
const search = req.query.search;
|
||||||
|
|
||||||
|
let whereClauses = [];
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
if (wallet) {
|
||||||
|
whereClauses.push("wallet_hash LIKE ?");
|
||||||
|
params.push(`%${wallet}%`);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
whereClauses.push("status = ?");
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
whereClauses.push("(session_id LIKE ? OR wallet_hash LIKE ? OR device_model LIKE ? OR weapon LIKE ? OR target_type LIKE ? OR image_filename LIKE ?)");
|
||||||
|
const s = `%${search}%`;
|
||||||
|
params.push(s, s, s, s, s, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
db.get(`SELECT COUNT(*) as total FROM upload_logs ${whereSql}`, params, (err, countRow) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur comptage logs:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur lors du comptage des logs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = countRow ? countRow.total : 0;
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM upload_logs
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY timestamp DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(query, [...params, limit, offset], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur lecture logs:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur lors de la lecture des logs' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
logs: rows,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour les statistiques des logs
|
||||||
|
app.get('/api/logs/stats', (req, res) => {
|
||||||
|
const statsQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_uploads,
|
||||||
|
SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||||
|
SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_count,
|
||||||
|
SUM(CASE WHEN date(timestamp, 'localtime') = date('now', 'localtime') THEN 1 ELSE 0 END) as today_uploads,
|
||||||
|
COUNT(DISTINCT wallet_hash) as unique_wallets,
|
||||||
|
SUM(COALESCE(file_size, 0)) as total_bytes,
|
||||||
|
MAX(timestamp) as latest_upload
|
||||||
|
FROM upload_logs
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.get(statsQuery, [], (err, stats) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur stats logs:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur calcul statistiques' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
stats: stats || {
|
||||||
|
total_uploads: 0,
|
||||||
|
success_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
today_uploads: 0,
|
||||||
|
unique_wallets: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
latest_upload: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour exporter les logs (CSV ou JSON)
|
||||||
|
app.get('/api/logs/export', (req, res) => {
|
||||||
|
const format = req.query.format || 'csv';
|
||||||
|
|
||||||
|
db.all('SELECT * FROM upload_logs ORDER BY timestamp DESC, id DESC', [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur export logs:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur export logs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.json`);
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
return res.send(JSON.stringify(rows, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV Export
|
||||||
|
const headers = [
|
||||||
|
'ID', 'Date/Heure', 'Statut', 'Wallet Hash', 'Session ID',
|
||||||
|
'Image', 'JSON', 'Taille (octets)', 'Cible', 'Arme', 'Distance (m)',
|
||||||
|
'Impacts', 'Modèle Appareil', 'OS', 'IP', 'Erreur'
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvLines = [headers.join(';')];
|
||||||
|
|
||||||
|
rows.forEach(r => {
|
||||||
|
const line = [
|
||||||
|
r.id,
|
||||||
|
`"${r.timestamp || ''}"`,
|
||||||
|
`"${r.status || ''}"`,
|
||||||
|
`"${r.wallet_hash || ''}"`,
|
||||||
|
`"${r.session_id || ''}"`,
|
||||||
|
`"${r.image_filename || ''}"`,
|
||||||
|
`"${r.json_filename || ''}"`,
|
||||||
|
r.file_size || 0,
|
||||||
|
`"${r.target_type || ''}"`,
|
||||||
|
`"${r.weapon || ''}"`,
|
||||||
|
r.distance_meters || '',
|
||||||
|
r.impacts_count || 0,
|
||||||
|
`"${r.device_model || ''}"`,
|
||||||
|
`"${r.device_os || ''}"`,
|
||||||
|
`"${r.ip_address || ''}"`,
|
||||||
|
`"${(r.error_message || '').replace(/"/g, '""')}"`
|
||||||
|
];
|
||||||
|
csvLines.push(line.join(';'));
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.csv`);
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.send('\uFEFF' + csvLines.join('\r\n'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour supprimer un log spécifique
|
||||||
|
app.delete('/api/logs/:id', (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
db.run('DELETE FROM upload_logs WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur suppression log:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur lors de la suppression' });
|
||||||
|
}
|
||||||
|
res.json({ message: 'Log supprimé avec succès', deletedId: id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour vider les logs
|
||||||
|
app.delete('/api/logs', (req, res) => {
|
||||||
|
db.run('DELETE FROM upload_logs', [], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Erreur vidage logs:", err);
|
||||||
|
return res.status(500).json({ error: 'Erreur lors de la réinitialisation des logs' });
|
||||||
|
}
|
||||||
|
res.json({ message: 'Tous les logs ont été effacés', changes: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour récupérer toutes les photos disponibles
|
||||||
|
app.get('/api/photos', (req, res) => {
|
||||||
|
try {
|
||||||
|
fs.readdir(imagesDir, (err, files) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const photos = files.map(file => ({
|
||||||
|
filename: file,
|
||||||
|
url: `/uploads/images/${file}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({ photos });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour récupérer les statistiques d'un wallet_hash + statut de modération
|
||||||
|
app.get('/api/stats/:wallet_hash', (req, res) => {
|
||||||
|
const walletHash = req.params.wallet_hash;
|
||||||
|
|
||||||
|
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, statRow) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (bErr, bannedRow) => {
|
||||||
|
const isBanned = !!bannedRow;
|
||||||
|
const stats = statRow || { photo_count: 0, last_upload: null };
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
stats,
|
||||||
|
is_banned: isBanned,
|
||||||
|
ban_reason: bannedRow ? bannedRow.reason : null,
|
||||||
|
banned_at: bannedRow ? bannedRow.banned_at : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route directe pour vérifier l'état de modération d'un wallet
|
||||||
|
app.get('/api/moderation/status/:wallet_hash', (req, res) => {
|
||||||
|
const walletHash = req.params.wallet_hash;
|
||||||
|
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, bannedRow) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Erreur lecture statut modération' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
wallet_hash: walletHash,
|
||||||
|
is_banned: !!bannedRow,
|
||||||
|
ban_reason: bannedRow ? bannedRow.reason : null,
|
||||||
|
banned_at: bannedRow ? bannedRow.banned_at : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour récupérer TOUS les contributeurs
|
||||||
|
app.get('/api/contributors', (req, res) => {
|
||||||
|
db.all('SELECT * FROM user_stats ORDER BY photo_count DESC', [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Erreur lors de la lecture des contributeurs' });
|
||||||
|
}
|
||||||
|
res.json({ contributors: rows });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour récupérer toutes les données (Photos + JSON)
|
||||||
|
app.get('/api/all-data', (req, res) => {
|
||||||
|
try {
|
||||||
|
fs.readdir(imagesDir, (err, files) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allData = files.map(file => {
|
||||||
|
const baseName = path.parse(file).name;
|
||||||
|
const jsonPath = path.join(dataDir, `${baseName}.json`);
|
||||||
|
let plotting = null;
|
||||||
|
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
try {
|
||||||
|
plotting = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Erreur lecture JSON pour ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: file,
|
||||||
|
imageUrl: `/uploads/images/${file}`,
|
||||||
|
data: plotting
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ data: allData });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour mettre à jour les données JSON d'une session
|
||||||
|
app.put('/api/data/:filename', (req, res) => {
|
||||||
|
const filename = req.params.filename;
|
||||||
|
const baseName = path.parse(filename).name;
|
||||||
|
const jsonPath = path.join(dataDir, `${baseName}.json`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(jsonPath)) {
|
||||||
|
return res.status(404).json({ error: 'Fichier JSON non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// On écrase le fichier avec les nouvelles données reçues
|
||||||
|
const newData = req.body;
|
||||||
|
fs.writeFileSync(jsonPath, JSON.stringify(newData, null, 2));
|
||||||
|
|
||||||
|
console.log(`Données mises à jour pour : ${baseName}.json`);
|
||||||
|
res.status(200).json({ message: 'Données mises à jour avec succès' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la mise à jour du JSON:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur lors de la mise à jour' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour exporter toutes les données en ZIP
|
||||||
|
app.get('/api/export/zip', (req, res) => {
|
||||||
|
try {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
|
||||||
|
// Ajouter les dossiers au ZIP
|
||||||
|
if (fs.existsSync(imagesDir)) zip.addLocalFolder(imagesDir, 'images');
|
||||||
|
if (fs.existsSync(dataDir)) zip.addLocalFolder(dataDir, 'data');
|
||||||
|
|
||||||
|
const zipFilename = `dataset_export_${Date.now()}.zip`;
|
||||||
|
const zipPath = path.join(exportsDir, zipFilename);
|
||||||
|
|
||||||
|
// Sauvegarder sur le disque
|
||||||
|
zip.writeZip(zipPath);
|
||||||
|
|
||||||
|
// Envoyer le fichier au client
|
||||||
|
res.download(zipPath, zipFilename, (err) => {
|
||||||
|
if (err) console.error("Erreur lors de l'envoi du ZIP:", err);
|
||||||
|
// Optionnel: supprimer le ZIP local après envoi si vous voulez gagner de la place
|
||||||
|
// fs.unlinkSync(zipPath);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la création du ZIP:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur lors de la création de l\'archive' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour archiver et vider les dossiers uploads
|
||||||
|
app.post('/api/export/archive', (req, res) => {
|
||||||
|
try {
|
||||||
|
const archiveName = `archive_${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
||||||
|
const archivePath = path.join(exportsDir, archiveName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(archivePath)) {
|
||||||
|
fs.mkdirSync(archivePath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour déplacer les fichiers
|
||||||
|
const moveFiles = (source, destSubDir) => {
|
||||||
|
const dest = path.join(archivePath, destSubDir);
|
||||||
|
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
||||||
|
|
||||||
|
const files = fs.readdirSync(source);
|
||||||
|
files.forEach(file => {
|
||||||
|
// Ne pas déplacer la base de données sqlite
|
||||||
|
if (file === 'database.sqlite') return;
|
||||||
|
|
||||||
|
const oldPath = path.join(source, file);
|
||||||
|
const newPath = path.join(dest, file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(oldPath, newPath);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback si renameSync échoue (ex: disque différent)
|
||||||
|
fs.copyFileSync(oldPath, newPath);
|
||||||
|
fs.unlinkSync(oldPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
moveFiles(imagesDir, 'images');
|
||||||
|
moveFiles(dataDir, 'data');
|
||||||
|
|
||||||
|
res.json({ message: 'Données archivées avec succès', archive: archiveName });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'archivage:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur lors de l\'archivage des données' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Servir les fichiers statiques (pour pouvoir accéder aux photos via URL)
|
||||||
|
app.use('/uploads/images', express.static(imagesDir));
|
||||||
|
|
||||||
|
// Gestion des erreurs
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(400).json({ error: 'Le fichier est trop volumineux (limite 15MB)' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(err.stack);
|
||||||
|
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intégration du Dashboard Next.js
|
||||||
|
const dashboardDir = path.join(__dirname, 'dashboard');
|
||||||
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
|
let nextApp;
|
||||||
|
try {
|
||||||
|
const next = require('next');
|
||||||
|
nextApp = next({ dev, dir: dashboardDir });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Module 'next' non disponible, mode API seule.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
if (nextApp) {
|
||||||
|
try {
|
||||||
|
await nextApp.prepare();
|
||||||
|
const handle = nextApp.getRequestHandler();
|
||||||
|
app.use((req, res) => handle(req, res));
|
||||||
|
console.log("Dashboard Next.js initialisé avec succès.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur d'initialisation du Dashboard Next.js:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`=================================`);
|
||||||
|
console.log(`Serveur Backend IA & Dashboard démarré`);
|
||||||
|
console.log(`Port: ${PORT}`);
|
||||||
|
console.log(`Dossiers:`);
|
||||||
|
console.log(` - Images: ${imagesDir}`);
|
||||||
|
console.log(` - Data : ${dataDir}`);
|
||||||
|
console.log(` - Export: ${exportsDir}`);
|
||||||
|
console.log(`=================================`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
const cvPromise = require('@techstark/opencv-js');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
|
let cvInstance = null;
|
||||||
|
|
||||||
|
async function getCV() {
|
||||||
|
if (!cvInstance) {
|
||||||
|
cvInstance = await cvPromise;
|
||||||
|
}
|
||||||
|
return cvInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de validation OpenCV pour vérifier si une image reçue est bien une cible de tir.
|
||||||
|
*/
|
||||||
|
class TargetValidator {
|
||||||
|
/**
|
||||||
|
* Analyse une image et renvoie le diagnostic OpenCV
|
||||||
|
* @param {string|Buffer} imageInput - Chemin du fichier ou Buffer de l'image
|
||||||
|
* @returns {Promise<{
|
||||||
|
* isValid: boolean,
|
||||||
|
* status: 'VALID' | 'SUSPICIOUS' | 'INVALID',
|
||||||
|
* confidence: number,
|
||||||
|
* ringsCount: number,
|
||||||
|
* bestCenter: { x: number, y: number, radius: number } | null,
|
||||||
|
* details: string
|
||||||
|
* }>}
|
||||||
|
*/
|
||||||
|
static async validateTarget(imageInput) {
|
||||||
|
const cv = await getCV();
|
||||||
|
|
||||||
|
let src = null;
|
||||||
|
let gray = null;
|
||||||
|
let blurred = null;
|
||||||
|
let circles = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Charger et décoder l'image avec Sharp pour obtenir les pixels bruts RGBA
|
||||||
|
const { data, info } = await sharp(imageInput)
|
||||||
|
.ensureAlpha() // 4 canaux RGBA
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
const width = info.width;
|
||||||
|
const height = info.height;
|
||||||
|
const minDim = Math.min(width, height);
|
||||||
|
|
||||||
|
// 2. Créer une Mat OpenCV à partir des pixels RGBA
|
||||||
|
src = new cv.Mat(height, width, cv.CV_8UC4);
|
||||||
|
src.data.set(data);
|
||||||
|
|
||||||
|
// 3. Conversion en niveaux de gris
|
||||||
|
gray = new cv.Mat();
|
||||||
|
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
|
||||||
|
|
||||||
|
// 4. Flou Gaussien pour réduire le bruit
|
||||||
|
blurred = new cv.Mat();
|
||||||
|
const ksize = new cv.Size(9, 9);
|
||||||
|
cv.GaussianBlur(gray, blurred, ksize, 2, 2, cv.BORDER_DEFAULT);
|
||||||
|
|
||||||
|
// 5. Détection multi-bandes pour capturer tous les anneaux concentriques
|
||||||
|
// (Puisque minDist dans HoughCircles empêche la détection de cercles concentriques sur un seul passage)
|
||||||
|
const detectedCircles = [];
|
||||||
|
const bands = [
|
||||||
|
{ min: Math.floor(minDim * 0.04), max: Math.floor(minDim * 0.18), p2: 45 },
|
||||||
|
{ min: Math.floor(minDim * 0.18), max: Math.floor(minDim * 0.35), p2: 48 },
|
||||||
|
{ min: Math.floor(minDim * 0.35), max: Math.floor(minDim * 0.55), p2: 50 }
|
||||||
|
];
|
||||||
|
|
||||||
|
circles = new cv.Mat();
|
||||||
|
|
||||||
|
for (const band of bands) {
|
||||||
|
cv.HoughCircles(
|
||||||
|
blurred,
|
||||||
|
circles,
|
||||||
|
cv.HOUGH_GRADIENT,
|
||||||
|
1,
|
||||||
|
Math.max(Math.floor(minDim * 0.05), 10),
|
||||||
|
100,
|
||||||
|
band.p2,
|
||||||
|
band.min,
|
||||||
|
band.max
|
||||||
|
);
|
||||||
|
|
||||||
|
if (circles.cols > 0 && circles.data32F) {
|
||||||
|
for (let i = 0; i < circles.cols; i++) {
|
||||||
|
const x = circles.data32F[i * 3];
|
||||||
|
const y = circles.data32F[i * 3 + 1];
|
||||||
|
const r = circles.data32F[i * 3 + 2];
|
||||||
|
if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
|
||||||
|
detectedCircles.push({ x, y, r });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si aucun cercle dans les bandes, tentative globale tolérante
|
||||||
|
if (detectedCircles.length === 0) {
|
||||||
|
cv.HoughCircles(
|
||||||
|
blurred,
|
||||||
|
circles,
|
||||||
|
cv.HOUGH_GRADIENT,
|
||||||
|
1,
|
||||||
|
Math.max(Math.floor(minDim * 0.08), 10),
|
||||||
|
100,
|
||||||
|
35,
|
||||||
|
Math.floor(minDim * 0.05),
|
||||||
|
Math.floor(minDim * 0.55)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (circles.cols > 0 && circles.data32F) {
|
||||||
|
for (let i = 0; i < circles.cols; i++) {
|
||||||
|
const x = circles.data32F[i * 3];
|
||||||
|
const y = circles.data32F[i * 3 + 1];
|
||||||
|
const r = circles.data32F[i * 3 + 2];
|
||||||
|
if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
|
||||||
|
detectedCircles.push({ x, y, r });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si aucun cercle détecté
|
||||||
|
if (detectedCircles.length === 0) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
status: 'INVALID',
|
||||||
|
confidence: 0.02,
|
||||||
|
ringsCount: 0,
|
||||||
|
bestCenter: null,
|
||||||
|
details: 'Aucun motif circulaire ou cible détecté sur cette photo.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Clustering des cercles pour trouver les anneaux concentriques
|
||||||
|
const tolerance = minDim * 0.07; // Tolérance de 7% pour le décalage de centre
|
||||||
|
const clusters = [];
|
||||||
|
|
||||||
|
for (const circle of detectedCircles) {
|
||||||
|
let added = false;
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
// Prendre le centre moyen actuel du cluster
|
||||||
|
const avgClusterX = cluster.reduce((sum, c) => sum + c.x, 0) / cluster.length;
|
||||||
|
const avgClusterY = cluster.reduce((sum, c) => sum + c.y, 0) / cluster.length;
|
||||||
|
const dist = Math.sqrt(Math.pow(circle.x - avgClusterX, 2) + Math.pow(circle.y - avgClusterY, 2));
|
||||||
|
|
||||||
|
if (dist < tolerance) {
|
||||||
|
// Vérifier que le rayon n'est pas un doublon exact (< 10px de différence)
|
||||||
|
const isDuplicate = cluster.some(c => Math.abs(c.r - circle.r) < minDim * 0.03);
|
||||||
|
if (!isDuplicate) {
|
||||||
|
cluster.push(circle);
|
||||||
|
}
|
||||||
|
added = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!added) {
|
||||||
|
clusters.push([circle]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trier les clusters par nombre de cercles concentriques décroissant
|
||||||
|
clusters.sort((a, b) => b.length - a.length);
|
||||||
|
const bestCluster = clusters[0];
|
||||||
|
const ringsCount = bestCluster.length;
|
||||||
|
|
||||||
|
// Calculer le centre moyen du meilleur cluster
|
||||||
|
const avgX = bestCluster.reduce((acc, c) => acc + c.x, 0) / ringsCount;
|
||||||
|
const avgY = bestCluster.reduce((acc, c) => acc + c.y, 0) / ringsCount;
|
||||||
|
const maxR = Math.max(...bestCluster.map(c => c.r));
|
||||||
|
|
||||||
|
// 7. Évaluation du statut et de la confiance
|
||||||
|
if (ringsCount >= 3) {
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
status: 'VALID',
|
||||||
|
confidence: Math.min(0.92 + (ringsCount - 3) * 0.02, 0.99),
|
||||||
|
ringsCount,
|
||||||
|
bestCenter: { x: avgX, y: avgY, radius: maxR },
|
||||||
|
details: `Cible certifiée : ${ringsCount} anneaux concentriques identifiés.`
|
||||||
|
};
|
||||||
|
} else if (ringsCount === 2) {
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
status: 'VALID',
|
||||||
|
confidence: 0.85,
|
||||||
|
ringsCount,
|
||||||
|
bestCenter: { x: avgX, y: avgY, radius: maxR },
|
||||||
|
details: `Cible confirmée : ${ringsCount} anneaux concentriques détectés.`
|
||||||
|
};
|
||||||
|
} else if (ringsCount === 1) {
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
status: 'SUSPICIOUS',
|
||||||
|
confidence: 0.50,
|
||||||
|
ringsCount: 1,
|
||||||
|
bestCenter: { x: avgX, y: avgY, radius: maxR },
|
||||||
|
details: 'Un seul anneau détecté, confirmation visuelle recommandée.'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
status: 'INVALID',
|
||||||
|
confidence: 0.1,
|
||||||
|
ringsCount: 0,
|
||||||
|
bestCenter: null,
|
||||||
|
details: 'Motif de cible non conforme.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur TargetValidator OpenCV:', error);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
status: 'INVALID',
|
||||||
|
confidence: 0.0,
|
||||||
|
ringsCount: 0,
|
||||||
|
bestCenter: null,
|
||||||
|
details: `Erreur d'analyse OpenCV: ${error.message}`
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// 8. Nettoyage mémoire
|
||||||
|
if (src && !src.isDeleted()) src.delete();
|
||||||
|
if (gray && !gray.isDeleted()) gray.delete();
|
||||||
|
if (blurred && !blurred.isDeleted()) blurred.delete();
|
||||||
|
if (circles && !circles.isDeleted()) circles.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TargetValidator;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
Running Gradle task 'assembleDebug'...
|
||||||
|
|
||||||
|
FAILURE: Build failed with an exception.
|
||||||
|
|
||||||
|
* What went wrong:
|
||||||
|
Execution failed for task ':app:processDebugResources'.
|
||||||
|
> A failure occurred while executing com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$TaskAction
|
||||||
|
> Android resource linking failed
|
||||||
|
ERROR: C:\Users\streaper2\Documents\00 - projet\bully\build\cunning_document_scanner\intermediates\merged_manifest\debug\processDebugManifest\AndroidManifest.xml:9:5-65: AAPT: error: unexpected element <uses-permission> found in <manifest><application>.
|
||||||
|
|
||||||
|
|
||||||
|
* Try:
|
||||||
|
> Run with --stacktrace option to get the stack trace.
|
||||||
|
> Run with --info or --debug option to get more log output.
|
||||||
|
> Run with --scan to get full insights.
|
||||||
|
> Get more help at https://help.gradle.org.
|
||||||
|
|
||||||
|
BUILD FAILED in 5s
|
||||||
|
Running Gradle task 'assembleDebug'... 5,4s
|
||||||
|
Gradle task assembleDebug failed with exit code 1
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: backendia
|
||||||
|
|
||||||
|
services:
|
||||||
|
backendia:
|
||||||
|
build:
|
||||||
|
context: ./backendia
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: backendia-prod
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3005:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
volumes:
|
||||||
|
- backendia_uploads:/app/uploads
|
||||||
|
- backendia_exports:/app/exports
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backendia_uploads:
|
||||||
|
backendia_exports:
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Documentation du Projet Bully
|
||||||
|
|
||||||
|
Bienvenue dans la documentation développeur de l'application **Bully**.
|
||||||
|
|
||||||
|
Ce projet est une application Flutter d'analyse de cibles de tir (Impact Detection).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Le code source est organisé dans le dossier `lib/` selon les couches suivantes :
|
||||||
|
|
||||||
|
- **Features (`lib/features`)** : Contient les écrans et la logique UI (Vues/Pages). C'est ici que réside l'interface utilisateur.
|
||||||
|
- **Services (`lib/services`)** : Services "métier" et utilitaires (traitement d'image, calculs, etc.). Indépendant de l'UI.
|
||||||
|
- **Data (`lib/data`)** : Gestion des données (Modèles, Base de données locale, Repositories).
|
||||||
|
|
||||||
|
## Sections de la Documentation
|
||||||
|
|
||||||
|
Pour plus de détails sur chaque partie, consultez les sections dédiées :
|
||||||
|
|
||||||
|
- 🏗️ **[Services (Logique Métier)](services/README.md)** : Documentation des services comme le traitement d'image et le calcul de score.
|
||||||
|
- 📱 **[Vues & Features (UI)](features/README.md)** : Documentation des écrans principaux (ex: Analyse).
|
||||||
|
- 💾 **[Base de Données & Modèles](data/README.md)** : Structure des données et persistance.
|
||||||
|
|
||||||
|
## Pour commencer
|
||||||
|
|
||||||
|
1. Assurez-vous d'avoir Flutter installé.
|
||||||
|
2. Lancez `flutter run` pour démarrer l'application.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Data & Persistance
|
||||||
|
|
||||||
|
Cette couche gère la sauvegarde et la récupération des données.
|
||||||
|
|
||||||
|
## Base de Données
|
||||||
|
L'application utilise une base de données locale (probablement SQLite/Drift ou Hive, à vérifier dans `lib/data/database`).
|
||||||
|
|
||||||
|
## Modèles (`lib/data/models`)
|
||||||
|
Les classes représentant les objets métier persistés.
|
||||||
|
|
||||||
|
Exemples probables :
|
||||||
|
- `Session` : Une session de tir.
|
||||||
|
- `Impact` : Un impact de balle sur la cible.
|
||||||
|
- `Target` : Configuration d'une cible.
|
||||||
|
|
||||||
|
## Repositories (`lib/data/repositories`)
|
||||||
|
Le pattern Repository est utilisé pour abstraire la source de données (DB locale, API distante, etc.) du reste de l'application.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Features & Vues
|
||||||
|
|
||||||
|
Cette section documente les écrans principaux de l'application et leur organisation.
|
||||||
|
|
||||||
|
## Écrans Principaux
|
||||||
|
|
||||||
|
### Analysis (`lib/features/analysis`)
|
||||||
|
C'est le cœur de l'application. Il permet à l'utilisateur de prendre une photo ou choisir une image pour analyser les impacts.
|
||||||
|
|
||||||
|
- **AnalysisScreen** (`analysis_screen.dart`): L'écran principal qui orchestre la capture et l'affichage des résultats.
|
||||||
|
- **AnalysisProvider** (`analysis_provider.dart`): Gestionnaire d'état (State Management) pour cet écran. Il fait le pont entre la vue et les services.
|
||||||
|
|
||||||
|
## Structure d'une Feature
|
||||||
|
Chaque feature est généralement composée de :
|
||||||
|
- `_screen.dart` : Le Widget de la page.
|
||||||
|
- `_provider.dart` : La logique d'état (ChangeNotifier, Bloc, etc.).
|
||||||
|
- `widgets/` : Widgets spécifiques à cette feature.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Services
|
||||||
|
|
||||||
|
Les services contiennent la logique métier de l'application, isolée de l'interface utilisateur.
|
||||||
|
|
||||||
|
## Liste des Services Principaux
|
||||||
|
|
||||||
|
| Service | Description | Fichier |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **ImageProcessingService** | Gère le traitement lourd des images (filtres, détection). | `lib/services/image_processing_service.dart` |
|
||||||
|
| **DistortionCorrection** | Corrige la distorsion de perspective des cibles. | `lib/services/distortion_correction_service.dart` |
|
||||||
|
| **ScoreCalculator** | Calcule le score en fonction des impacts détectés. | `lib/services/score_calculator_service.dart` |
|
||||||
|
| **StatisticsService** | Génère des statistiques sur les sessions de tir. | `lib/services/statistics_service.dart` |
|
||||||
|
|
||||||
|
## Exemple d'utilisation (Fictif)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Exemple d'appel au service de calcul de score
|
||||||
|
final calculator = ScoreCalculatorService();
|
||||||
|
final score = calculator.calculate(impacts);
|
||||||
|
```
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'accéder à vos photos pour analyser vos cibles enregistrées.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'utiliser l'appareil photo pour scanner vos cibles en temps réel.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'accéder au micro (requis par certains services de caméra même si non utilisé).</string>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'core/theme/theme_provider.dart';
|
||||||
|
import 'main_navigation_holder.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
class BullyApp extends StatelessWidget {
|
class BullyApp extends StatelessWidget {
|
||||||
const BullyApp({super.key});
|
const BullyApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<ThemeProvider>(
|
||||||
|
builder: (context, themeProvider, child) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Bully - Analyse de Cibles',
|
title: 'Bully - Analyse de Cibles',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme,
|
theme: themeProvider.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: themeProvider.darkTheme,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: themeProvider.themeMode,
|
||||||
home: const HomeScreen(),
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [
|
||||||
|
Locale('fr', 'FR'), // Français
|
||||||
|
],
|
||||||
|
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
||||||
|
home: MainNavigationHolder(key: mainNavKey),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ class AppConstants {
|
|||||||
|
|
||||||
// Database
|
// Database
|
||||||
static const String databaseName = 'bully_targets.db';
|
static const String databaseName = 'bully_targets.db';
|
||||||
static const int databaseVersion = 1;
|
static const int databaseVersion = 6;
|
||||||
|
|
||||||
// Tables
|
// Tables
|
||||||
static const String sessionsTable = 'sessions';
|
static const String sessionsTable = 'sessions';
|
||||||
|
static const String targetAnalysesTable = 'target_analyses';
|
||||||
static const String shotsTable = 'shots';
|
static const String shotsTable = 'shots';
|
||||||
|
static const String weaponsTable = 'weapons';
|
||||||
|
static const String maintenanceTable = 'weapon_maintenance';
|
||||||
|
|
||||||
// Image processing
|
// Image processing
|
||||||
static const double minImpactRadius = 2.0;
|
static const double minImpactRadius = 2.0;
|
||||||
|
|||||||
@@ -1,90 +1,425 @@
|
|||||||
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._();
|
||||||
|
|
||||||
static const Color primaryColor = Color(0xFF1E88E5);
|
// Accents par défaut (compatibilité statique)
|
||||||
static const Color secondaryColor = Color(0xFF43A047);
|
static const Color primaryColor = Color(0xFF2563EB);
|
||||||
static const Color errorColor = Color(0xFFE53935);
|
static const Color primaryLight = Color(0xFF60A5FA);
|
||||||
static const Color warningColor = Color(0xFFFFA726);
|
static const Color primaryDark = Color(0xFF1D4ED8);
|
||||||
static const Color successColor = Color(0xFF66BB6A);
|
|
||||||
|
|
||||||
static const Color backgroundColor = Color(0xFFF5F5F5);
|
static const Color secondaryColor = Color(0xFF10B981);
|
||||||
static const Color surfaceColor = Colors.white;
|
static const Color secondaryDark = Color(0xFF059669);
|
||||||
static const Color textPrimary = Color(0xFF212121);
|
|
||||||
static const Color textSecondary = Color(0xFF757575);
|
|
||||||
|
|
||||||
// Impact colors for visualization
|
static const Color accentBlue = Color(0xFF0EA5E9);
|
||||||
static const Color impactColor = Color(0xFFFF5722);
|
static const Color accentGold = Color(0xFFFFB300);
|
||||||
|
|
||||||
|
static const Color errorColor = Color(0xFFEF4444);
|
||||||
|
static const Color warningColor = Color(0xFFF59E0B);
|
||||||
|
static const Color successColor = Color(0xFF10B981);
|
||||||
|
|
||||||
|
// Palette Thème Sombre (Standard Stand de Tir)
|
||||||
|
static const Color darkBackground = Color(0xFF0D1219);
|
||||||
|
static const Color darkSurface = Color(0xFF161E28);
|
||||||
|
static const Color darkSurfaceElevated = Color(0xFF1E2836);
|
||||||
|
static const Color darkSurfaceVariant = Color(0xFF253243);
|
||||||
|
static const Color darkBorder = Color(0xFF2C3B4E);
|
||||||
|
static const Color darkBorderLight = Color(0xFF3B4D65);
|
||||||
|
static const Color darkTextPrimary = Color(0xFFF1F5F9);
|
||||||
|
static const Color darkTextSecondary = Color(0xFF94A3B8);
|
||||||
|
static const Color darkTextMuted = Color(0xFF64748B);
|
||||||
|
|
||||||
|
// Palette Thème Clair
|
||||||
|
static const Color lightBackground = Color(0xFFF8FAFC);
|
||||||
|
static const Color lightSurface = Color(0xFFFFFFFF);
|
||||||
|
static const Color lightSurfaceElevated = Color(0xFFFFFFFF);
|
||||||
|
static const Color lightSurfaceVariant = Color(0xFFF1F5F9);
|
||||||
|
static const Color lightBorder = Color(0xFFE2E8F0);
|
||||||
|
static const Color lightTextPrimary = Color(0xFF0F172A);
|
||||||
|
static const Color lightTextSecondary = Color(0xFF64748B);
|
||||||
|
static const Color lightTextMuted = Color(0xFF94A3B8);
|
||||||
|
|
||||||
|
// Compatibilité ascendante
|
||||||
|
static const Color backgroundColor = lightBackground;
|
||||||
|
static const Color surfaceColor = lightSurface;
|
||||||
|
static const Color textPrimary = lightTextPrimary;
|
||||||
|
static const Color textSecondary = lightTextSecondary;
|
||||||
|
|
||||||
|
// Couleurs des impacts pour l'overlay
|
||||||
|
static const Color impactColor = Color(0xFFFF3D00);
|
||||||
static const Color impactOutlineColor = Color(0xFFFFFFFF);
|
static const Color impactOutlineColor = Color(0xFFFFFFFF);
|
||||||
static const Color groupingCenterColor = Color(0xFF2196F3);
|
static const Color groupingCenterColor = Color(0xFF00E5FF);
|
||||||
static const Color groupingCircleColor = Color(0x4D2196F3);
|
static const Color groupingCircleColor = Color(0x4D00E5FF);
|
||||||
|
|
||||||
// Score zone colors
|
// Couleurs des zones de score cibles concentriques
|
||||||
static const List<Color> zoneColors = [
|
static const List<Color> zoneColors = [
|
||||||
Color(0xFFFFEB3B), // Zone 10 - Gold
|
Color(0xFFFFB300), // Zone 10 - Or
|
||||||
Color(0xFFFFEB3B), // Zone 9
|
Color(0xFFFFCA28), // Zone 9
|
||||||
Color(0xFFFF5722), // Zone 8
|
Color(0xFFFF5722), // Zone 8
|
||||||
Color(0xFFFF5722), // Zone 7
|
Color(0xFFFF7043), // Zone 7
|
||||||
Color(0xFF2196F3), // Zone 6
|
Color(0xFF29B6F6), // Zone 6
|
||||||
Color(0xFF2196F3), // Zone 5
|
Color(0xFF4FC3F7), // Zone 5
|
||||||
Color(0xFF4CAF50), // Zone 4
|
Color(0xFF66BB6A), // Zone 4
|
||||||
Color(0xFF4CAF50), // Zone 3
|
Color(0xFF81C784), // Zone 3
|
||||||
Color(0xFFFFFFFF), // Zone 2
|
Color(0xFFFFFFFF), // Zone 2
|
||||||
Color(0xFFFFFFFF), // Zone 1
|
Color(0xFFE0E0E0), // Zone 1
|
||||||
];
|
];
|
||||||
|
|
||||||
static ThemeData get lightTheme {
|
static ThemeData get lightTheme => buildLightTheme(AppAccentColor.blue);
|
||||||
|
static ThemeData get darkTheme => buildDarkTheme(AppAccentColor.blue);
|
||||||
|
|
||||||
|
static ThemeData buildLightTheme(AppAccentColor accent) {
|
||||||
|
final activePrimary = accent.color;
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: primaryColor,
|
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
|
colorScheme: ColorScheme.light(
|
||||||
|
primary: activePrimary,
|
||||||
|
secondary: secondaryColor,
|
||||||
|
surface: lightSurface,
|
||||||
|
error: errorColor,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
onSurface: lightTextPrimary,
|
||||||
|
onError: Colors.white,
|
||||||
|
outline: lightBorder,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: backgroundColor,
|
scaffoldBackgroundColor: lightBackground,
|
||||||
|
cardColor: lightSurface,
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
backgroundColor: primaryColor,
|
backgroundColor: lightSurface,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: lightTextPrimary,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: lightTextPrimary,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 2,
|
elevation: 0,
|
||||||
|
color: lightSurface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: const BorderSide(color: lightBorder, width: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
backgroundColor: activePrimary,
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: lightTextPrimary,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
side: const BorderSide(color: lightBorder, width: 1.5),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: lightSurfaceVariant,
|
||||||
|
selectedColor: activePrimary.withValues(alpha: 0.15),
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: lightTextPrimary,
|
||||||
|
),
|
||||||
|
secondaryLabelStyle: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: activePrimary,
|
||||||
|
),
|
||||||
|
side: const BorderSide(color: lightBorder, width: 1),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: lightSurfaceVariant,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: lightBorder),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: lightBorder),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: activePrimary, width: 1.8),
|
||||||
|
),
|
||||||
|
labelStyle: const TextStyle(color: lightTextSecondary),
|
||||||
|
hintStyle: const TextStyle(color: lightTextMuted),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(
|
||||||
|
color: lightBorder,
|
||||||
|
thickness: 1,
|
||||||
|
space: 24,
|
||||||
|
),
|
||||||
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: activePrimary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 3,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static ThemeData get darkTheme {
|
static ThemeData buildDarkTheme(AppAccentColor accent) {
|
||||||
|
final activePrimary = accent.color;
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: primaryColor,
|
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
|
colorScheme: ColorScheme.dark(
|
||||||
|
primary: activePrimary,
|
||||||
|
secondary: secondaryColor,
|
||||||
|
surface: darkSurface,
|
||||||
|
error: errorColor,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
onSurface: darkTextPrimary,
|
||||||
|
onError: Colors.white,
|
||||||
|
outline: darkBorder,
|
||||||
),
|
),
|
||||||
|
scaffoldBackgroundColor: darkBackground,
|
||||||
|
cardColor: darkSurface,
|
||||||
appBarTheme: const AppBarTheme(
|
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: 2,
|
elevation: 0,
|
||||||
|
color: darkSurface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: const BorderSide(color: darkBorder, width: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: activePrimary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: darkTextPrimary,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
side: const BorderSide(color: darkBorder, width: 1.5),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: darkSurfaceElevated,
|
||||||
|
selectedColor: activePrimary.withValues(alpha: 0.2),
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: darkTextPrimary,
|
||||||
|
),
|
||||||
|
secondaryLabelStyle: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: activePrimary,
|
||||||
|
),
|
||||||
|
side: const BorderSide(color: darkBorder, width: 1),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: darkSurfaceElevated,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: darkBorder),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: darkBorder),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: activePrimary, width: 1.8),
|
||||||
|
),
|
||||||
|
labelStyle: const TextStyle(color: darkTextSecondary),
|
||||||
|
hintStyle: const TextStyle(color: darkTextMuted),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(
|
||||||
|
color: darkBorder,
|
||||||
|
thickness: 1,
|
||||||
|
space: 24,
|
||||||
|
),
|
||||||
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: activePrimary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 3,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
|
|
||||||
|
class ThemeProvider with ChangeNotifier {
|
||||||
|
static const String _themeModeKey = 'user_theme_mode';
|
||||||
|
static const String _accentKey = 'user_accent_color';
|
||||||
|
|
||||||
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
AppAccentColor _accent = AppAccentColor.blue;
|
||||||
|
|
||||||
|
ThemeMode get themeMode => _themeMode;
|
||||||
|
AppAccentColor get currentAccent => _accent;
|
||||||
|
Color get primaryColor => _accent.color;
|
||||||
|
|
||||||
|
ThemeData get lightTheme => AppTheme.buildLightTheme(_accent);
|
||||||
|
ThemeData get darkTheme => AppTheme.buildDarkTheme(_accent);
|
||||||
|
|
||||||
|
ThemeProvider() {
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final modeIndex = prefs.getInt(_themeModeKey);
|
||||||
|
final accentId = prefs.getString(_accentKey);
|
||||||
|
|
||||||
|
if (modeIndex != null) {
|
||||||
|
_themeMode = ThemeMode.values[modeIndex];
|
||||||
|
}
|
||||||
|
if (accentId != null) {
|
||||||
|
_accent = AppAccentColor.fromId(accentId);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setThemeMode(ThemeMode mode) async {
|
||||||
|
if (_themeMode == mode) return;
|
||||||
|
|
||||||
|
_themeMode = mode;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_themeModeKey, mode.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAccent(AppAccentColor accent) async {
|
||||||
|
if (_accent.id == accent.id) return;
|
||||||
|
|
||||||
|
_accent = accent;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_accentKey, accent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get themeModeName {
|
||||||
|
switch (_themeMode) {
|
||||||
|
case ThemeMode.system:
|
||||||
|
return 'Automatique';
|
||||||
|
case ThemeMode.light:
|
||||||
|
return 'Clair';
|
||||||
|
case ThemeMode.dark:
|
||||||
|
return 'Sombre';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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 innerContent = Container(
|
||||||
|
padding: padding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: resolvedBg,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
border: Border.all(color: resolvedBorderColor, width: 1.2),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget content = blur > 0
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: innerContent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
child: innerContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
content = Container(
|
||||||
|
margin: margin,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
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: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/// Bouton d'information (ⓘ) qui explique des métriques à l'utilisateur.
|
||||||
|
///
|
||||||
|
/// Affiche une petite icône cliquable ; au clic, une boîte de dialogue
|
||||||
|
/// détaille la signification de chaque statistique de la carte associée.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Explication d'une métrique : un libellé et sa description.
|
||||||
|
class MetricExplanation {
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
const MetricExplanation(this.label, this.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetricInfoButton extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<MetricExplanation> explanations;
|
||||||
|
|
||||||
|
const MetricInfoButton({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.explanations,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(Icons.info_outline, size: 18, color: Colors.grey[500]),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
tooltip: 'À quoi ça correspond ?',
|
||||||
|
onPressed: () => _showInfo(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showInfo(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final e in explanations)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
e.label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(e.description),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Compris'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../data/models/weapon.dart';
|
||||||
|
|
||||||
|
/// Widget affichant une icône vectorielle stylisée et fidèle pour chaque type d'arme.
|
||||||
|
class WeaponTypeIcon extends StatelessWidget {
|
||||||
|
final WeaponType type;
|
||||||
|
final Color? color;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const WeaponTypeIcon({
|
||||||
|
super.key,
|
||||||
|
required this.type,
|
||||||
|
this.color,
|
||||||
|
this.size = 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final effectiveColor = color ?? Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
|
return CustomPaint(
|
||||||
|
size: Size(size, size),
|
||||||
|
painter: _WeaponTypePainter(
|
||||||
|
type: type,
|
||||||
|
color: effectiveColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeaponTypePainter extends CustomPainter {
|
||||||
|
final WeaponType type;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
_WeaponTypePainter({
|
||||||
|
required this.type,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final fillPaint = Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.fill
|
||||||
|
..isAntiAlias = true;
|
||||||
|
|
||||||
|
final strokePaint = Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.0
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..isAntiAlias = true;
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
// Normalisation sur une grille 32x32 pour un rendu très précis
|
||||||
|
final scale = size.width / 32.0;
|
||||||
|
canvas.scale(scale, scale);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case WeaponType.handgun:
|
||||||
|
_drawGlock(canvas, fillPaint, strokePaint);
|
||||||
|
break;
|
||||||
|
case WeaponType.rifle:
|
||||||
|
_drawKalashnikov(canvas, fillPaint, strokePaint);
|
||||||
|
break;
|
||||||
|
case WeaponType.shotgun:
|
||||||
|
_drawPumpShotgun(canvas, fillPaint, strokePaint);
|
||||||
|
break;
|
||||||
|
case WeaponType.airgun:
|
||||||
|
_drawAirgun(canvas, fillPaint, strokePaint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silhouette fidèle d'un Glock : culasse carrée "boxy", pontet angulaire et poignée ergonomique
|
||||||
|
void _drawGlock(Canvas canvas, Paint fillPaint, Paint strokePaint) {
|
||||||
|
// 1. Culasse rectangulaire Glock (Slide)
|
||||||
|
final slidePath = Path();
|
||||||
|
slidePath.moveTo(4, 9);
|
||||||
|
slidePath.lineTo(26.5, 9); // dessus de culasse
|
||||||
|
slidePath.lineTo(27.5, 10); // chanfrein nez avant
|
||||||
|
slidePath.lineTo(27.5, 14.5); // bouche du canon
|
||||||
|
slidePath.lineTo(4, 14.5); // bas de culasse
|
||||||
|
slidePath.lineTo(3.5, 13); // chanfrein arrière
|
||||||
|
slidePath.lineTo(3.5, 10);
|
||||||
|
slidePath.close();
|
||||||
|
canvas.drawPath(slidePath, fillPaint);
|
||||||
|
|
||||||
|
// Organes de visée Glock
|
||||||
|
// Cran de mire arrière carré
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(5, 7.2, 2.5, 1.8), fillPaint);
|
||||||
|
// Guidon avant
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(24.5, 7.2, 1.8, 1.8), fillPaint);
|
||||||
|
|
||||||
|
// 2. Carcasse inférieure & Poignée (Frame & Grip)
|
||||||
|
final framePath = Path();
|
||||||
|
framePath.moveTo(4, 14.5);
|
||||||
|
framePath.lineTo(26, 14.5); // rail picatinny avant
|
||||||
|
framePath.lineTo(25.5, 16.5);
|
||||||
|
framePath.lineTo(17.5, 16.5); // bas du rail avant pontet
|
||||||
|
framePath.lineTo(17.5, 19.5); // face avant du pontet carré Glock
|
||||||
|
framePath.lineTo(12.5, 20.5); // bas du pontet
|
||||||
|
framePath.lineTo(12, 17.5); // jonction détente / poignée avant
|
||||||
|
framePath.lineTo(9.5, 27); // poignée avant avec empreinte
|
||||||
|
framePath.lineTo(4.5, 27); // talon de chargeur / magwell
|
||||||
|
framePath.lineTo(8, 16.5); // dos de poignée / beavertail Glock
|
||||||
|
framePath.lineTo(4, 15.5);
|
||||||
|
framePath.close();
|
||||||
|
canvas.drawPath(framePath, fillPaint);
|
||||||
|
|
||||||
|
// 3. Queue de détente Safe Action
|
||||||
|
final triggerPath = Path();
|
||||||
|
triggerPath.moveTo(15.5, 15.5);
|
||||||
|
triggerPath.quadraticBezierTo(14, 17.5, 15.2, 19);
|
||||||
|
canvas.drawPath(triggerPath, strokePaint..strokeWidth = 1.3);
|
||||||
|
|
||||||
|
// 4. Stries de préhension arrière (Serrations)
|
||||||
|
for (double i = 6; i <= 10; i += 1.5) {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(i, 10),
|
||||||
|
Offset(i, 13.5),
|
||||||
|
Paint()
|
||||||
|
..color = Colors.black.withValues(alpha: 0.35)
|
||||||
|
..strokeWidth = 0.8,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silhouette fidèle d'une Kalashnikov (AK-47 / AKM) : chargeur banane, emprunt de gaz et guidon haut
|
||||||
|
void _drawKalashnikov(Canvas canvas, Paint fillPaint, Paint strokePaint) {
|
||||||
|
// 1. Crosse arrière en bois / épaule
|
||||||
|
final stockPath = Path();
|
||||||
|
stockPath.moveTo(1.5, 13);
|
||||||
|
stockPath.lineTo(8, 14.5);
|
||||||
|
stockPath.lineTo(8, 18);
|
||||||
|
stockPath.lineTo(1.5, 20.5);
|
||||||
|
stockPath.close();
|
||||||
|
canvas.drawPath(stockPath, fillPaint);
|
||||||
|
|
||||||
|
// 2. Boîtier de culasse (Receiver) & Capot supérieur
|
||||||
|
final receiverPath = Path();
|
||||||
|
receiverPath.moveTo(8, 13.5);
|
||||||
|
receiverPath.lineTo(17, 13.5);
|
||||||
|
receiverPath.lineTo(17, 18);
|
||||||
|
receiverPath.lineTo(8, 18);
|
||||||
|
receiverPath.close();
|
||||||
|
canvas.drawPath(receiverPath, fillPaint);
|
||||||
|
|
||||||
|
// 3. Garde-main & Tube d'emprunt de gaz supérieur
|
||||||
|
final handguardPath = Path();
|
||||||
|
handguardPath.moveTo(17, 13.5);
|
||||||
|
handguardPath.lineTo(24, 13.5);
|
||||||
|
handguardPath.lineTo(24, 17);
|
||||||
|
handguardPath.lineTo(17, 17);
|
||||||
|
handguardPath.close();
|
||||||
|
canvas.drawPath(handguardPath, fillPaint);
|
||||||
|
|
||||||
|
// 4. Canon fin avant & Bloc guidon Kalash
|
||||||
|
final barrelPath = Path();
|
||||||
|
barrelPath.moveTo(24, 14.5);
|
||||||
|
barrelPath.lineTo(30.5, 14.5);
|
||||||
|
barrelPath.lineTo(30.5, 16);
|
||||||
|
barrelPath.lineTo(24, 16);
|
||||||
|
barrelPath.close();
|
||||||
|
canvas.drawPath(barrelPath, fillPaint);
|
||||||
|
|
||||||
|
// Bloc guidon triangulaire surélevé avant
|
||||||
|
final frontSightPath = Path();
|
||||||
|
frontSightPath.moveTo(28, 14.5);
|
||||||
|
frontSightPath.lineTo(29, 11);
|
||||||
|
frontSightPath.lineTo(30, 14.5);
|
||||||
|
frontSightPath.close();
|
||||||
|
canvas.drawPath(frontSightPath, fillPaint);
|
||||||
|
|
||||||
|
// Hausse arrière au dessus du boîtier
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(16.5, 12, 2, 1.5), fillPaint);
|
||||||
|
|
||||||
|
// 5. Chargeur courbé "banane" emblématique (30 coups)
|
||||||
|
final magPath = Path();
|
||||||
|
magPath.moveTo(14, 18);
|
||||||
|
magPath.lineTo(16.5, 18);
|
||||||
|
magPath.quadraticBezierTo(17.5, 22.5, 15, 26);
|
||||||
|
magPath.lineTo(12, 25.5);
|
||||||
|
magPath.quadraticBezierTo(14, 22, 13.5, 18);
|
||||||
|
magPath.close();
|
||||||
|
canvas.drawPath(magPath, fillPaint);
|
||||||
|
|
||||||
|
// 6. Poignée pistolet séparée
|
||||||
|
final gripPath = Path();
|
||||||
|
gripPath.moveTo(8.5, 18);
|
||||||
|
gripPath.lineTo(10.5, 18);
|
||||||
|
gripPath.lineTo(9.5, 23.5);
|
||||||
|
gripPath.lineTo(7.5, 23);
|
||||||
|
gripPath.close();
|
||||||
|
canvas.drawPath(gripPath, fillPaint);
|
||||||
|
|
||||||
|
// 7. Pontet et détente
|
||||||
|
final triggerGuard = Path();
|
||||||
|
triggerGuard.moveTo(11, 18);
|
||||||
|
triggerGuard.quadraticBezierTo(11.5, 20, 13, 19.5);
|
||||||
|
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silhouette fidèle d'un Fusil à Pompe (Pump Action Shotgun : type Remington 870)
|
||||||
|
void _drawPumpShotgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
|
||||||
|
// 1. Crosse arrière traditionnelle ergonomique
|
||||||
|
final stockPath = Path();
|
||||||
|
stockPath.moveTo(1, 14.5);
|
||||||
|
stockPath.lineTo(9, 15.5);
|
||||||
|
stockPath.lineTo(9, 19.5);
|
||||||
|
stockPath.lineTo(6.5, 20); // poignée semi-pistolet
|
||||||
|
stockPath.lineTo(1, 21.5);
|
||||||
|
stockPath.close();
|
||||||
|
canvas.drawPath(stockPath, fillPaint);
|
||||||
|
|
||||||
|
// 2. Boîtier de culasse récepteur (Receiver)
|
||||||
|
final receiverPath = Path();
|
||||||
|
receiverPath.moveTo(9, 14);
|
||||||
|
receiverPath.lineTo(16.5, 14);
|
||||||
|
receiverPath.lineTo(16.5, 19);
|
||||||
|
receiverPath.lineTo(9, 19);
|
||||||
|
receiverPath.close();
|
||||||
|
canvas.drawPath(receiverPath, fillPaint);
|
||||||
|
|
||||||
|
// 3. Canon long calibre 12 (Barrel)
|
||||||
|
final barrelPath = Path();
|
||||||
|
barrelPath.moveTo(16.5, 14);
|
||||||
|
barrelPath.lineTo(31, 14);
|
||||||
|
barrelPath.lineTo(31, 15.5);
|
||||||
|
barrelPath.lineTo(16.5, 15.5);
|
||||||
|
barrelPath.close();
|
||||||
|
canvas.drawPath(barrelPath, fillPaint);
|
||||||
|
|
||||||
|
// Grain d'orge / mire avant sur le canon
|
||||||
|
canvas.drawCircle(const Offset(30, 13.2), 0.8, fillPaint);
|
||||||
|
|
||||||
|
// 4. Tube magasin inférieur sous le canon
|
||||||
|
final magTubePath = Path();
|
||||||
|
magTubePath.moveTo(16.5, 16);
|
||||||
|
magTubePath.lineTo(27, 16);
|
||||||
|
magTubePath.lineTo(27, 17.5);
|
||||||
|
magTubePath.lineTo(16.5, 17.5);
|
||||||
|
magTubePath.close();
|
||||||
|
canvas.drawPath(magTubePath, fillPaint);
|
||||||
|
|
||||||
|
// Bague de fixation canon / tube magasin
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(26, 14, 1.2, 4), fillPaint);
|
||||||
|
|
||||||
|
// 5. Pompe mobile striée d'actionnement (Forend / Pump)
|
||||||
|
final pumpPath = Path();
|
||||||
|
pumpPath.addRRect(RRect.fromRectAndRadius(
|
||||||
|
const Rect.fromLTWH(18, 15.5, 6.5, 3.5),
|
||||||
|
const Radius.circular(0.8),
|
||||||
|
));
|
||||||
|
canvas.drawPath(pumpPath, fillPaint);
|
||||||
|
|
||||||
|
// Stries sur la pompe
|
||||||
|
for (double x = 19.5; x <= 23.5; x += 1.3) {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(x, 16),
|
||||||
|
Offset(x, 18.5),
|
||||||
|
Paint()
|
||||||
|
..color = Colors.black.withValues(alpha: 0.4)
|
||||||
|
..strokeWidth = 0.6,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Pontet et détente
|
||||||
|
final triggerGuard = Path();
|
||||||
|
triggerGuard.moveTo(10, 19);
|
||||||
|
triggerGuard.quadraticBezierTo(11.5, 21.5, 13.5, 20);
|
||||||
|
triggerGuard.lineTo(13.5, 19);
|
||||||
|
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silhouette fidèle d'une arme à air comprimé / cible plomb (Airgun)
|
||||||
|
void _drawAirgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
|
||||||
|
// Pistolet match 10m / Airgun olympique avec réservoir d'air cylindrique
|
||||||
|
// 1. Canon supérieur et boîtier
|
||||||
|
final barrelPath = Path();
|
||||||
|
barrelPath.moveTo(4, 12);
|
||||||
|
barrelPath.lineTo(28, 12);
|
||||||
|
barrelPath.lineTo(28, 14);
|
||||||
|
barrelPath.lineTo(4, 14);
|
||||||
|
barrelPath.close();
|
||||||
|
canvas.drawPath(barrelPath, fillPaint);
|
||||||
|
|
||||||
|
// Organes de visée match : dioptre arrière et tunnel de guidon avant
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(4.5, 9.5, 3, 2.5), fillPaint);
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(25, 10, 2.5, 2), fillPaint);
|
||||||
|
|
||||||
|
// 2. Bonbonne d'air comprimé cylindrique inférieure (Cylinder)
|
||||||
|
final tankPath = Path();
|
||||||
|
tankPath.addRRect(RRect.fromRectAndRadius(
|
||||||
|
const Rect.fromLTWH(10, 14.5, 15, 3),
|
||||||
|
const Radius.circular(1.5),
|
||||||
|
));
|
||||||
|
canvas.drawPath(tankPath, fillPaint);
|
||||||
|
|
||||||
|
// Manomètre avant
|
||||||
|
canvas.drawCircle(const Offset(25, 16), 1.2, strokePaint..strokeWidth = 0.8);
|
||||||
|
|
||||||
|
// 3. Poignée anatomique bois de tir sportif avec repose-paume
|
||||||
|
final gripPath = Path();
|
||||||
|
gripPath.moveTo(6, 14);
|
||||||
|
gripPath.lineTo(10, 14);
|
||||||
|
gripPath.lineTo(9.5, 23.5);
|
||||||
|
gripPath.lineTo(3.5, 22.5);
|
||||||
|
gripPath.close();
|
||||||
|
canvas.drawPath(gripPath, fillPaint);
|
||||||
|
|
||||||
|
// Tablette repose-paume réglable
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
const Rect.fromLTWH(2, 22.5, 9, 2.2),
|
||||||
|
const Radius.circular(0.8),
|
||||||
|
),
|
||||||
|
fillPaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Pontet match
|
||||||
|
final guardPath = Path();
|
||||||
|
guardPath.moveTo(10, 14.5);
|
||||||
|
guardPath.quadraticBezierTo(12, 18.5, 9.5, 18.5);
|
||||||
|
canvas.drawPath(guardPath, strokePaint..strokeWidth = 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _WeaponTypePainter oldDelegate) {
|
||||||
|
return oldDelegate.type != type || oldDelegate.color != color;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
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';
|
||||||
|
import '../models/target_analysis.dart';
|
||||||
import '../models/shot.dart';
|
import '../models/shot.dart';
|
||||||
|
import '../models/weapon.dart';
|
||||||
|
import '../models/maintenance.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
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();
|
||||||
|
|
||||||
@@ -16,7 +25,9 @@ class DatabaseHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
_database ??= await _initDatabase();
|
if (_database != null) return _database!;
|
||||||
|
_initFuture ??= _initDatabase();
|
||||||
|
_database = await _initFuture!;
|
||||||
return _database!;
|
return _database!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +47,19 @@ class DatabaseHelper {
|
|||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE ${AppConstants.sessionsTable} (
|
CREATE TABLE ${AppConstants.sessionsTable} (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon TEXT NOT NULL,
|
||||||
|
weapon_id TEXT,
|
||||||
|
max_shots_per_target INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
distance INTEGER DEFAULT 25
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.targetAnalysesTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
target_type TEXT NOT NULL,
|
target_type TEXT NOT NULL,
|
||||||
image_path TEXT NOT NULL,
|
image_path TEXT NOT NULL,
|
||||||
total_score INTEGER NOT NULL,
|
total_score INTEGER NOT NULL,
|
||||||
@@ -46,7 +70,8 @@ class DatabaseHelper {
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
target_center_x REAL,
|
target_center_x REAL,
|
||||||
target_center_y REAL,
|
target_center_y REAL,
|
||||||
target_radius REAL
|
target_radius REAL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
@@ -56,48 +81,262 @@ class DatabaseHelper {
|
|||||||
x REAL NOT NULL,
|
x REAL NOT NULL,
|
||||||
y REAL NOT NULL,
|
y REAL NOT NULL,
|
||||||
score INTEGER NOT NULL,
|
score INTEGER NOT NULL,
|
||||||
session_id TEXT NOT NULL,
|
analysis_id TEXT NOT NULL,
|
||||||
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id)
|
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.weaponsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
caliber TEXT NOT NULL,
|
||||||
|
magazine_count INTEGER NOT NULL,
|
||||||
|
magazine_capacity INTEGER NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
optic TEXT,
|
||||||
|
silencer TEXT,
|
||||||
|
trigger TEXT,
|
||||||
|
custom_name TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.maintenanceTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
rounds_since_last INTEGER,
|
||||||
|
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
// Handle future database migrations here
|
if (oldVersion < 2) {
|
||||||
|
// Migration from single-target sessions to multi-target sessions
|
||||||
|
|
||||||
|
// 1. Rename existing sessions table to a temporary name
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} RENAME TO sessions_old');
|
||||||
|
|
||||||
|
// 2. Create the new sessions table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.sessionsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon TEXT NOT NULL,
|
||||||
|
max_shots_per_target INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
notes TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 3. Create the new target_analyses table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.targetAnalysesTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
total_score INTEGER NOT NULL,
|
||||||
|
grouping_diameter REAL,
|
||||||
|
grouping_center_x REAL,
|
||||||
|
grouping_center_y REAL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
target_center_x REAL,
|
||||||
|
target_center_y REAL,
|
||||||
|
target_radius REAL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 4. Update shots table (rename session_id to analysis_id)
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.shotsTable} RENAME TO shots_old');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.shotsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
x REAL NOT NULL,
|
||||||
|
y REAL NOT NULL,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
analysis_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 5. Migrate data
|
||||||
|
// For each old session, create one session and one target_analysis
|
||||||
|
final oldSessions = await db.query('sessions_old');
|
||||||
|
for (final old in oldSessions) {
|
||||||
|
final sessionId = old['id'] as String;
|
||||||
|
final analysisId = 'analysis_$sessionId'; // Create a unique ID for the analysis
|
||||||
|
|
||||||
|
// Insert into new sessions
|
||||||
|
await db.insert(AppConstants.sessionsTable, {
|
||||||
|
'id': sessionId,
|
||||||
|
'weapon': 'Inconnue',
|
||||||
|
'max_shots_per_target': 5,
|
||||||
|
'created_at': old['created_at'],
|
||||||
|
'notes': old['notes'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert into target_analyses
|
||||||
|
await db.insert(AppConstants.targetAnalysesTable, {
|
||||||
|
'id': analysisId,
|
||||||
|
'session_id': sessionId,
|
||||||
|
'target_type': old['target_type'],
|
||||||
|
'image_path': old['image_path'],
|
||||||
|
'total_score': old['total_score'],
|
||||||
|
'grouping_diameter': old['grouping_diameter'],
|
||||||
|
'grouping_center_x': old['grouping_center_x'],
|
||||||
|
'grouping_center_y': old['grouping_center_y'],
|
||||||
|
'created_at': old['created_at'],
|
||||||
|
'notes': null,
|
||||||
|
'target_center_x': old['target_center_x'],
|
||||||
|
'target_center_y': old['target_center_y'],
|
||||||
|
'target_radius': old['target_radius'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate shots from shots_old to shots
|
||||||
|
final oldShots = await db.query('shots_old', where: 'session_id = ?', whereArgs: [sessionId]);
|
||||||
|
for (final shot in oldShots) {
|
||||||
|
await db.insert(AppConstants.shotsTable, {
|
||||||
|
'id': shot['id'],
|
||||||
|
'x': shot['x'],
|
||||||
|
'y': shot['y'],
|
||||||
|
'score': shot['score'],
|
||||||
|
'analysis_id': analysisId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Cleanup
|
||||||
|
await db.execute('DROP TABLE sessions_old');
|
||||||
|
await db.execute('DROP TABLE shots_old');
|
||||||
|
|
||||||
|
// 7. Re-create indexes
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
// Add weapons table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.weaponsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
caliber TEXT NOT NULL,
|
||||||
|
magazine_count INTEGER NOT NULL,
|
||||||
|
magazine_capacity INTEGER NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// Add weapon_id to sessions table
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN weapon_id TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
// Add maintenance table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.maintenanceTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
rounds_since_last INTEGER,
|
||||||
|
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 5) {
|
||||||
|
// Add new weapon options columns
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN optic TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN silencer TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN "trigger" TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN custom_name TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
// Add distance column to sessions
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN distance INTEGER DEFAULT 25');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
Future<int> insertSession(Session session) async {
|
Future<int> insertSession(Session session) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return await db.transaction((txn) async {
|
return await db.transaction((txn) async {
|
||||||
|
// INTERCEPTION ET FIX DE LA DATE ICI :
|
||||||
|
// On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet
|
||||||
|
final Map<String, dynamic> sessionMap = Map.from(session.toMap());
|
||||||
|
sessionMap['created_at'] = session.createdAt.toIso8601String();
|
||||||
|
|
||||||
await txn.insert(
|
await txn.insert(
|
||||||
AppConstants.sessionsTable,
|
AppConstants.sessionsTable,
|
||||||
session.toMap(),
|
sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct !
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (final shot in session.shots) {
|
for (final analysis in session.analyses) {
|
||||||
// Ensure shot has correct session_id
|
await insertTargetAnalysisTx(txn, analysis, session.id);
|
||||||
final shotWithSessionId = shot.copyWith(sessionId: session.id);
|
|
||||||
await txn.insert(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
shotWithSessionId.toMap(),
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> insertTargetAnalysis(TargetAnalysis analysis, String sessionId) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await insertTargetAnalysisTx(txn, analysis, sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertTargetAnalysisTx(Transaction txn, TargetAnalysis analysis, String sessionId) async {
|
||||||
|
final analysisWithSessionId = analysis.copyWith(sessionId: sessionId);
|
||||||
|
await txn.insert(
|
||||||
|
AppConstants.targetAnalysesTable,
|
||||||
|
analysisWithSessionId.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final shot in analysis.shots) {
|
||||||
|
final shotWithAnalysisId = shot.copyWith(analysisId: analysis.id);
|
||||||
|
await txn.insert(
|
||||||
|
AppConstants.shotsTable,
|
||||||
|
shotWithAnalysisId.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Session?> getSession(String id) async {
|
Future<Session?> getSession(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final sessionMaps = await db.query(
|
final sessionMaps = await db.query(
|
||||||
@@ -108,104 +347,104 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
if (sessionMaps.isEmpty) return null;
|
if (sessionMaps.isEmpty) return null;
|
||||||
|
|
||||||
final shotMaps = await db.query(
|
final analysisMaps = await db.query(
|
||||||
AppConstants.shotsTable,
|
AppConstants.targetAnalysesTable,
|
||||||
where: 'session_id = ?',
|
where: 'session_id = ?',
|
||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final analyses = <TargetAnalysis>[];
|
||||||
|
for (final analysisMap in analysisMaps) {
|
||||||
|
final analysisId = analysisMap['id'] as String;
|
||||||
|
final shotMaps = await db.query(
|
||||||
|
AppConstants.shotsTable,
|
||||||
|
where: 'analysis_id = ?',
|
||||||
|
whereArgs: [analysisId],
|
||||||
|
);
|
||||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
||||||
return Session.fromMap(sessionMaps.first, shots);
|
analyses.add(TargetAnalysis.fromMap(analysisMap, shots));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Session.fromMap(sessionMaps.first, analyses);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Session>> getAllSessions({
|
Future<List<Session>> getAllSessions({
|
||||||
String? targetType,
|
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
||||||
String? whereClause;
|
|
||||||
List<dynamic>? whereArgs;
|
|
||||||
|
|
||||||
if (targetType != null) {
|
|
||||||
whereClause = 'target_type = ?';
|
|
||||||
whereArgs = [targetType];
|
|
||||||
}
|
|
||||||
|
|
||||||
final sessionMaps = await db.query(
|
final sessionMaps = await db.query(
|
||||||
AppConstants.sessionsTable,
|
AppConstants.sessionsTable,
|
||||||
where: whereClause,
|
|
||||||
whereArgs: whereArgs,
|
|
||||||
orderBy: 'created_at DESC',
|
orderBy: 'created_at DESC',
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
);
|
);
|
||||||
|
if (sessionMaps.isEmpty) return [];
|
||||||
|
|
||||||
// First, check if there are orphaned shots (with empty session_id)
|
// 3 requêtes groupées au lieu d'une cascade par session puis par analyse
|
||||||
// and only one session - if so, assign them to that session
|
// (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
|
||||||
if (sessionMaps.length == 1) {
|
// ce qui ralentissait l'accueil/historique/stats au fil des mois.
|
||||||
final orphanedShots = await db.query(
|
final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
|
||||||
AppConstants.shotsTable,
|
final analysisMaps = await _queryIn(
|
||||||
where: 'session_id = ?',
|
db,
|
||||||
whereArgs: [''],
|
AppConstants.targetAnalysesTable,
|
||||||
|
'session_id',
|
||||||
|
sessionIds,
|
||||||
);
|
);
|
||||||
if (orphanedShots.isNotEmpty) {
|
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
|
||||||
await db.update(
|
final shotMaps = await _queryIn(
|
||||||
|
db,
|
||||||
AppConstants.shotsTable,
|
AppConstants.shotsTable,
|
||||||
{'session_id': sessionMaps.first['id']},
|
'analysis_id',
|
||||||
where: 'session_id = ?',
|
analysisIds,
|
||||||
whereArgs: [''],
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
final shotsByAnalysis = <String, List<Shot>>{};
|
||||||
|
for (final map in shotMaps) {
|
||||||
|
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
|
||||||
|
.add(Shot.fromMap(map));
|
||||||
}
|
}
|
||||||
|
|
||||||
final sessions = <Session>[];
|
final analysesBySession = <String, List<TargetAnalysis>>{};
|
||||||
for (final sessionMap in sessionMaps) {
|
for (final map in analysisMaps) {
|
||||||
final sessionId = sessionMap['id'] as String;
|
final analysis = TargetAnalysis.fromMap(
|
||||||
|
map,
|
||||||
final shotMaps = await db.query(
|
shotsByAnalysis[map['id'] as String] ?? [],
|
||||||
AppConstants.shotsTable,
|
|
||||||
where: 'session_id = ?',
|
|
||||||
whereArgs: [sessionId],
|
|
||||||
);
|
);
|
||||||
|
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
|
||||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
|
||||||
sessions.add(Session.fromMap(sessionMap, shots));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions;
|
return sessionMaps
|
||||||
|
.map((m) =>
|
||||||
|
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> updateSession(Session session) async {
|
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
|
||||||
final db = await database;
|
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
|
||||||
return await db.transaction((txn) async {
|
Future<List<Map<String, Object?>>> _queryIn(
|
||||||
await txn.update(
|
Database db,
|
||||||
AppConstants.sessionsTable,
|
String table,
|
||||||
session.toMap(),
|
String column,
|
||||||
where: 'id = ?',
|
List<String> values,
|
||||||
whereArgs: [session.id],
|
) async {
|
||||||
);
|
if (values.isEmpty) return [];
|
||||||
|
const chunkSize = 500;
|
||||||
// Delete existing shots and insert new ones
|
final results = <Map<String, Object?>>[];
|
||||||
await txn.delete(
|
for (var i = 0; i < values.length; i += chunkSize) {
|
||||||
AppConstants.shotsTable,
|
final chunk = values.sublist(
|
||||||
where: 'session_id = ?',
|
i,
|
||||||
whereArgs: [session.id],
|
i + chunkSize > values.length ? values.length : i + chunkSize,
|
||||||
);
|
|
||||||
|
|
||||||
for (final shot in session.shots) {
|
|
||||||
// Ensure shot has correct session_id
|
|
||||||
final shotWithSessionId = shot.copyWith(sessionId: session.id);
|
|
||||||
await txn.insert(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
shotWithSessionId.toMap(),
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
);
|
||||||
|
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||||
|
results.addAll(await db.query(
|
||||||
|
table,
|
||||||
|
where: '$column IN ($placeholders)',
|
||||||
|
whereArgs: chunk,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
return results;
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> deleteSession(String id) async {
|
Future<int> deleteSession(String id) async {
|
||||||
@@ -229,12 +468,12 @@ class DatabaseHelper {
|
|||||||
) ?? 0;
|
) ?? 0;
|
||||||
|
|
||||||
final avgScore = (await db.rawQuery(
|
final avgScore = (await db.rawQuery(
|
||||||
'SELECT AVG(total_score) as avg FROM ${AppConstants.sessionsTable}',
|
'SELECT AVG(total_score) as avg FROM ${AppConstants.targetAnalysesTable}',
|
||||||
)).first['avg'] as double? ?? 0.0;
|
)).first['avg'] as double? ?? 0.0;
|
||||||
|
|
||||||
final bestScore = Sqflite.firstIntValue(
|
final bestScore = Sqflite.firstIntValue(
|
||||||
await db.rawQuery(
|
await db.rawQuery(
|
||||||
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}',
|
'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}',
|
||||||
),
|
),
|
||||||
) ?? 0;
|
) ?? 0;
|
||||||
|
|
||||||
@@ -246,9 +485,145 @@ class DatabaseHelper {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weapon operations
|
||||||
|
Future<int> insertWeapon(Weapon weapon) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.insert(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
weapon.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Weapon>> getAllWeapons() async {
|
||||||
|
final db = await database;
|
||||||
|
final List<Map<String, dynamic>> maps = await db.query(AppConstants.weaponsTable, orderBy: 'name ASC');
|
||||||
|
return List.generate(maps.length, (i) => Weapon.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Weapon?> getWeapon(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
final List<Map<String, dynamic>> maps = await db.query(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
if (maps.isEmpty) return null;
|
||||||
|
return Weapon.fromMap(maps.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> updateWeapon(Weapon weapon) async {
|
||||||
|
final db = await database;
|
||||||
|
|
||||||
|
// Start a transaction to ensure both updates succeed
|
||||||
|
return await db.transaction((txn) async {
|
||||||
|
// 1. Update weapon in armory
|
||||||
|
final count = await txn.update(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
weapon.toMap(),
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [weapon.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Propagate name change to all sessions linked to this weapon
|
||||||
|
await txn.update(
|
||||||
|
AppConstants.sessionsTable,
|
||||||
|
{'weapon': weapon.displayName},
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
whereArgs: [weapon.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> deleteWeapon(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
|
||||||
|
return await db.transaction((txn) async {
|
||||||
|
// 1. Unlink sessions from this weapon but KEEP the name (gravé à jamais)
|
||||||
|
await txn.update(
|
||||||
|
AppConstants.sessionsTable,
|
||||||
|
{'weapon_id': null},
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Delete the weapon record
|
||||||
|
return await txn.delete(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weapon statistics & Maintenance
|
||||||
|
Future<int> getRoundsFiredForWeapon(String weaponId) async {
|
||||||
|
final db = await database;
|
||||||
|
final result = await db.rawQuery('''
|
||||||
|
SELECT COUNT(s.id) as count
|
||||||
|
FROM ${AppConstants.shotsTable} s
|
||||||
|
JOIN ${AppConstants.targetAnalysesTable} ta ON s.analysis_id = ta.id
|
||||||
|
JOIN ${AppConstants.sessionsTable} sess ON ta.session_id = sess.id
|
||||||
|
WHERE sess.weapon_id = ?
|
||||||
|
''', [weaponId]);
|
||||||
|
return Sqflite.firstIntValue(result) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getSessionCountForWeapon(String weaponId) async {
|
||||||
|
final db = await database;
|
||||||
|
final result = await db.rawQuery('''
|
||||||
|
SELECT COUNT(id) as count
|
||||||
|
FROM ${AppConstants.sessionsTable}
|
||||||
|
WHERE weapon_id = ?
|
||||||
|
''', [weaponId]);
|
||||||
|
return Sqflite.firstIntValue(result) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> insertMaintenance(MaintenanceEntry entry) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.insert(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
entry.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toutes les entrées de maintenance, armes confondues (export/sauvegarde).
|
||||||
|
Future<List<MaintenanceEntry>> getAllMaintenance() async {
|
||||||
|
final db = await database;
|
||||||
|
final maps = await db.query(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
orderBy: 'date DESC',
|
||||||
|
);
|
||||||
|
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
|
||||||
|
final db = await database;
|
||||||
|
final maps = await db.query(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
orderBy: 'date DESC',
|
||||||
|
whereArgs: [weaponId],
|
||||||
|
);
|
||||||
|
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> deleteMaintenance(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.delete(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
_database = null;
|
_database = null;
|
||||||
|
_initFuture = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
enum MaintenanceType {
|
||||||
|
cleaning('Nettoyage'),
|
||||||
|
repair('Réparation'),
|
||||||
|
inspection('Inspection'),
|
||||||
|
upgrade('Amélioration'),
|
||||||
|
other('Autre');
|
||||||
|
|
||||||
|
final String displayName;
|
||||||
|
const MaintenanceType(this.displayName);
|
||||||
|
|
||||||
|
static MaintenanceType fromString(String value) {
|
||||||
|
return MaintenanceType.values.firstWhere(
|
||||||
|
(type) => type.name == value,
|
||||||
|
orElse: () => MaintenanceType.other,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaintenanceEntry {
|
||||||
|
final String id;
|
||||||
|
final String weaponId;
|
||||||
|
final MaintenanceType type;
|
||||||
|
final String description;
|
||||||
|
final DateTime date;
|
||||||
|
final int? roundsSinceLastMaintenance;
|
||||||
|
|
||||||
|
MaintenanceEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.weaponId,
|
||||||
|
required this.type,
|
||||||
|
required this.description,
|
||||||
|
required this.date,
|
||||||
|
this.roundsSinceLastMaintenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'weapon_id': weaponId,
|
||||||
|
'type': type.name,
|
||||||
|
'description': description,
|
||||||
|
'date': date.toIso8601String(),
|
||||||
|
'rounds_since_last': roundsSinceLastMaintenance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MaintenanceEntry.fromMap(Map<String, dynamic> map) {
|
||||||
|
return MaintenanceEntry(
|
||||||
|
id: map['id'] as String,
|
||||||
|
weaponId: map['weapon_id'] as String,
|
||||||
|
type: MaintenanceType.fromString(map['type'] as String),
|
||||||
|
description: map['description'] as String,
|
||||||
|
date: DateTime.parse(map['date'] as String),
|
||||||
|
roundsSinceLastMaintenance: map['rounds_since_last'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,112 +1,83 @@
|
|||||||
import 'shot.dart';
|
import 'target_analysis.dart';
|
||||||
import 'target_type.dart';
|
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
final String id;
|
final String id;
|
||||||
final TargetType targetType;
|
final String weapon;
|
||||||
final String imagePath;
|
final String? weaponId;
|
||||||
final List<Shot> shots;
|
final int maxShotsPerTarget;
|
||||||
final int totalScore;
|
final List<TargetAnalysis> analyses;
|
||||||
final double? groupingDiameter;
|
|
||||||
final double? groupingCenterX;
|
|
||||||
final double? groupingCenterY;
|
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? notes;
|
final String? notes;
|
||||||
|
final int distance; // Shooting distance in meters
|
||||||
// Target detection data
|
|
||||||
final double? targetCenterX;
|
|
||||||
final double? targetCenterY;
|
|
||||||
final double? targetRadius;
|
|
||||||
|
|
||||||
Session({
|
Session({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.targetType,
|
required this.weapon,
|
||||||
required this.imagePath,
|
this.weaponId,
|
||||||
required this.shots,
|
required this.maxShotsPerTarget,
|
||||||
required this.totalScore,
|
required this.analyses,
|
||||||
this.groupingDiameter,
|
|
||||||
this.groupingCenterX,
|
|
||||||
this.groupingCenterY,
|
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.notes,
|
this.notes,
|
||||||
this.targetCenterX,
|
this.distance = 25, // Default distance
|
||||||
this.targetCenterY,
|
|
||||||
this.targetRadius,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
int get shotCount => shots.length;
|
int get targetCount => analyses.length;
|
||||||
|
|
||||||
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
|
int get totalShots => analyses.fold(0, (sum, a) => sum + a.shotCount);
|
||||||
|
|
||||||
|
int get totalScore => analyses.fold(0, (sum, a) => sum + a.totalScore);
|
||||||
|
|
||||||
|
double get averageScore => analyses.isEmpty ? 0.0 : totalScore / analyses.length;
|
||||||
|
|
||||||
Session copyWith({
|
Session copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
TargetType? targetType,
|
String? weapon,
|
||||||
String? imagePath,
|
String? weaponId,
|
||||||
List<Shot>? shots,
|
int? maxShotsPerTarget,
|
||||||
int? totalScore,
|
List<TargetAnalysis>? analyses,
|
||||||
double? groupingDiameter,
|
|
||||||
double? groupingCenterX,
|
|
||||||
double? groupingCenterY,
|
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? notes,
|
String? notes,
|
||||||
double? targetCenterX,
|
int? distance,
|
||||||
double? targetCenterY,
|
|
||||||
double? targetRadius,
|
|
||||||
}) {
|
}) {
|
||||||
return Session(
|
return Session(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
targetType: targetType ?? this.targetType,
|
weapon: weapon ?? this.weapon,
|
||||||
imagePath: imagePath ?? this.imagePath,
|
weaponId: weaponId ?? this.weaponId,
|
||||||
shots: shots ?? this.shots,
|
maxShotsPerTarget: maxShotsPerTarget ?? this.maxShotsPerTarget,
|
||||||
totalScore: totalScore ?? this.totalScore,
|
analyses: analyses ?? this.analyses,
|
||||||
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
|
|
||||||
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
|
|
||||||
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
|
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
targetCenterX: targetCenterX ?? this.targetCenterX,
|
distance: distance ?? this.distance,
|
||||||
targetCenterY: targetCenterY ?? this.targetCenterY,
|
|
||||||
targetRadius: targetRadius ?? this.targetRadius,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
'target_type': targetType.name,
|
'weapon': weapon,
|
||||||
'image_path': imagePath,
|
'weapon_id': weaponId,
|
||||||
'total_score': totalScore,
|
'max_shots_per_target': maxShotsPerTarget,
|
||||||
'grouping_diameter': groupingDiameter,
|
|
||||||
'grouping_center_x': groupingCenterX,
|
|
||||||
'grouping_center_y': groupingCenterY,
|
|
||||||
'created_at': createdAt.toIso8601String(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
'notes': notes,
|
'notes': notes,
|
||||||
'target_center_x': targetCenterX,
|
'distance': distance,
|
||||||
'target_center_y': targetCenterY,
|
|
||||||
'target_radius': targetRadius,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
factory Session.fromMap(Map<String, dynamic> map, List<Shot> shots) {
|
factory Session.fromMap(Map<String, dynamic> map, List<TargetAnalysis> analyses) {
|
||||||
return Session(
|
return Session(
|
||||||
id: map['id'] as String,
|
id: map['id'] as String,
|
||||||
targetType: TargetType.fromString(map['target_type'] as String),
|
weapon: map['weapon'] as String? ?? 'Inconnue',
|
||||||
imagePath: map['image_path'] as String,
|
weaponId: map['weapon_id'] as String?,
|
||||||
shots: shots,
|
maxShotsPerTarget: map['max_shots_per_target'] as int? ?? 5,
|
||||||
totalScore: map['total_score'] as int,
|
analyses: analyses,
|
||||||
groupingDiameter: map['grouping_diameter'] as double?,
|
|
||||||
groupingCenterX: map['grouping_center_x'] as double?,
|
|
||||||
groupingCenterY: map['grouping_center_y'] as double?,
|
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: DateTime.parse(map['created_at'] as String),
|
||||||
notes: map['notes'] as String?,
|
notes: map['notes'] as String?,
|
||||||
targetCenterX: map['target_center_x'] as double?,
|
distance: map['distance'] as int? ?? 25,
|
||||||
targetCenterY: map['target_center_y'] as double?,
|
|
||||||
targetRadius: map['target_radius'] as double?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'Session(id: $id, targetType: $targetType, shots: ${shots.length}, totalScore: $totalScore, createdAt: $createdAt)';
|
return 'Session(id: $id, weapon: $weapon, distance: ${distance}m, targets: ${analyses.length}, createdAt: $createdAt)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ class Shot {
|
|||||||
final double x; // Relative position (0.0 - 1.0)
|
final double x; // Relative position (0.0 - 1.0)
|
||||||
final double y; // Relative position (0.0 - 1.0)
|
final double y; // Relative position (0.0 - 1.0)
|
||||||
final int score;
|
final int score;
|
||||||
final String sessionId;
|
final String analysisId;
|
||||||
|
|
||||||
Shot({
|
Shot({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.x,
|
required this.x,
|
||||||
required this.y,
|
required this.y,
|
||||||
required this.score,
|
required this.score,
|
||||||
required this.sessionId,
|
required this.analysisId,
|
||||||
});
|
});
|
||||||
|
|
||||||
Shot copyWith({
|
Shot copyWith({
|
||||||
@@ -18,14 +18,14 @@ class Shot {
|
|||||||
double? x,
|
double? x,
|
||||||
double? y,
|
double? y,
|
||||||
int? score,
|
int? score,
|
||||||
String? sessionId,
|
String? analysisId,
|
||||||
}) {
|
}) {
|
||||||
return Shot(
|
return Shot(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
x: x ?? this.x,
|
x: x ?? this.x,
|
||||||
y: y ?? this.y,
|
y: y ?? this.y,
|
||||||
score: score ?? this.score,
|
score: score ?? this.score,
|
||||||
sessionId: sessionId ?? this.sessionId,
|
analysisId: analysisId ?? this.analysisId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class Shot {
|
|||||||
'x': x,
|
'x': x,
|
||||||
'y': y,
|
'y': y,
|
||||||
'score': score,
|
'score': score,
|
||||||
'session_id': sessionId,
|
'analysis_id': analysisId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,13 +45,13 @@ class Shot {
|
|||||||
x: (map['x'] as num).toDouble(),
|
x: (map['x'] as num).toDouble(),
|
||||||
y: (map['y'] as num).toDouble(),
|
y: (map['y'] as num).toDouble(),
|
||||||
score: map['score'] as int,
|
score: map['score'] as int,
|
||||||
sessionId: map['session_id'] as String,
|
analysisId: map['analysis_id'] as String? ?? map['session_id'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'Shot(id: $id, x: $x, y: $y, score: $score, sessionId: $sessionId)';
|
return 'Shot(id: $id, x: $x, y: $y, score: $score, analysisId: $analysisId)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -62,11 +62,11 @@ class Shot {
|
|||||||
other.x == x &&
|
other.x == x &&
|
||||||
other.y == y &&
|
other.y == y &&
|
||||||
other.score == score &&
|
other.score == score &&
|
||||||
other.sessionId == sessionId;
|
other.analysisId == analysisId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode {
|
||||||
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ sessionId.hashCode;
|
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ analysisId.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import 'shot.dart';
|
||||||
|
import 'target_type.dart';
|
||||||
|
|
||||||
|
class TargetAnalysis {
|
||||||
|
final String id;
|
||||||
|
final String sessionId;
|
||||||
|
final TargetType targetType;
|
||||||
|
final String imagePath;
|
||||||
|
final List<Shot> shots;
|
||||||
|
final int totalScore;
|
||||||
|
final double? groupingDiameter;
|
||||||
|
final double? groupingCenterX;
|
||||||
|
final double? groupingCenterY;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
// Target detection data
|
||||||
|
final double? targetCenterX;
|
||||||
|
final double? targetCenterY;
|
||||||
|
final double? targetRadius;
|
||||||
|
|
||||||
|
TargetAnalysis({
|
||||||
|
required this.id,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.targetType,
|
||||||
|
required this.imagePath,
|
||||||
|
required this.shots,
|
||||||
|
required this.totalScore,
|
||||||
|
this.groupingDiameter,
|
||||||
|
this.groupingCenterX,
|
||||||
|
this.groupingCenterY,
|
||||||
|
required this.createdAt,
|
||||||
|
this.notes,
|
||||||
|
this.targetCenterX,
|
||||||
|
this.targetCenterY,
|
||||||
|
this.targetRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
int get shotCount => shots.length;
|
||||||
|
|
||||||
|
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
|
||||||
|
|
||||||
|
TargetAnalysis copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sessionId,
|
||||||
|
TargetType? targetType,
|
||||||
|
String? imagePath,
|
||||||
|
List<Shot>? shots,
|
||||||
|
int? totalScore,
|
||||||
|
double? groupingDiameter,
|
||||||
|
double? groupingCenterX,
|
||||||
|
double? groupingCenterY,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? notes,
|
||||||
|
double? targetCenterX,
|
||||||
|
double? targetCenterY,
|
||||||
|
double? targetRadius,
|
||||||
|
}) {
|
||||||
|
return TargetAnalysis(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
targetType: targetType ?? this.targetType,
|
||||||
|
imagePath: imagePath ?? this.imagePath,
|
||||||
|
shots: shots ?? this.shots,
|
||||||
|
totalScore: totalScore ?? this.totalScore,
|
||||||
|
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
|
||||||
|
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
|
||||||
|
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
targetCenterX: targetCenterX ?? this.targetCenterX,
|
||||||
|
targetCenterY: targetCenterY ?? this.targetCenterY,
|
||||||
|
targetRadius: targetRadius ?? this.targetRadius,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'session_id': sessionId,
|
||||||
|
'target_type': targetType.name,
|
||||||
|
'image_path': imagePath,
|
||||||
|
'total_score': totalScore,
|
||||||
|
'grouping_diameter': groupingDiameter,
|
||||||
|
'grouping_center_x': groupingCenterX,
|
||||||
|
'grouping_center_y': groupingCenterY,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'notes': notes,
|
||||||
|
'target_center_x': targetCenterX,
|
||||||
|
'target_center_y': targetCenterY,
|
||||||
|
'target_radius': targetRadius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory TargetAnalysis.fromMap(Map<String, dynamic> map, List<Shot> shots) {
|
||||||
|
return TargetAnalysis(
|
||||||
|
id: map['id'] as String,
|
||||||
|
sessionId: map['session_id'] as String,
|
||||||
|
targetType: TargetType.fromString(map['target_type'] as String),
|
||||||
|
imagePath: map['image_path'] as String,
|
||||||
|
shots: shots,
|
||||||
|
totalScore: map['total_score'] as int,
|
||||||
|
groupingDiameter: map['grouping_diameter'] as double?,
|
||||||
|
groupingCenterX: map['grouping_center_x'] as double?,
|
||||||
|
groupingCenterY: map['grouping_center_y'] as double?,
|
||||||
|
createdAt: DateTime.parse(map['created_at'] as String),
|
||||||
|
notes: map['notes'] as String?,
|
||||||
|
targetCenterX: map['target_center_x'] as double?,
|
||||||
|
targetCenterY: map['target_center_y'] as double?,
|
||||||
|
targetRadius: map['target_radius'] as double?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum WeaponType {
|
||||||
|
handgun('Arme de Poing', Icons.shield),
|
||||||
|
rifle('Arme d\'Épaule', Icons.filter_center_focus),
|
||||||
|
shotgun('Fusil à Pompe', Icons.splitscreen),
|
||||||
|
airgun('Airsoft / Airgun', Icons.air);
|
||||||
|
|
||||||
|
final String displayName;
|
||||||
|
final IconData defaultIcon;
|
||||||
|
const WeaponType(this.displayName, this.defaultIcon);
|
||||||
|
|
||||||
|
static WeaponType fromString(String value) {
|
||||||
|
return WeaponType.values.firstWhere(
|
||||||
|
(type) => type.name == value,
|
||||||
|
orElse: () => WeaponType.handgun,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Weapon {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final WeaponType type;
|
||||||
|
final String caliber;
|
||||||
|
final int magazineCount;
|
||||||
|
final int magazineCapacity;
|
||||||
|
final String? notes;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
// New options
|
||||||
|
final String? optic;
|
||||||
|
final String? silencer;
|
||||||
|
final String? trigger;
|
||||||
|
final String? customName;
|
||||||
|
|
||||||
|
String get displayName => (customName != null && customName!.isNotEmpty) ? customName! : name;
|
||||||
|
|
||||||
|
Weapon({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
required this.caliber,
|
||||||
|
required this.magazineCount,
|
||||||
|
required this.magazineCapacity,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
this.optic,
|
||||||
|
this.silencer,
|
||||||
|
this.trigger,
|
||||||
|
this.customName,
|
||||||
|
});
|
||||||
|
|
||||||
|
Weapon copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
WeaponType? type,
|
||||||
|
String? caliber,
|
||||||
|
int? magazineCount,
|
||||||
|
int? magazineCapacity,
|
||||||
|
String? notes,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? optic,
|
||||||
|
String? silencer,
|
||||||
|
String? trigger,
|
||||||
|
String? customName,
|
||||||
|
}) {
|
||||||
|
return Weapon(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
type: type ?? this.type,
|
||||||
|
caliber: caliber ?? this.caliber,
|
||||||
|
magazineCount: magazineCount ?? this.magazineCount,
|
||||||
|
magazineCapacity: magazineCapacity ?? this.magazineCapacity,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
optic: optic ?? this.optic,
|
||||||
|
silencer: silencer ?? this.silencer,
|
||||||
|
trigger: trigger ?? this.trigger,
|
||||||
|
customName: customName ?? this.customName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'type': type.name,
|
||||||
|
'caliber': caliber,
|
||||||
|
'magazine_count': magazineCount,
|
||||||
|
'magazine_capacity': magazineCapacity,
|
||||||
|
'notes': notes,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'optic': optic,
|
||||||
|
'silencer': silencer,
|
||||||
|
'trigger': trigger,
|
||||||
|
'custom_name': customName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Weapon.fromMap(Map<String, dynamic> map) {
|
||||||
|
return Weapon(
|
||||||
|
id: map['id'] as String,
|
||||||
|
name: map['name'] as String,
|
||||||
|
type: WeaponType.fromString(map['type'] as String),
|
||||||
|
caliber: map['caliber'] as String,
|
||||||
|
magazineCount: map['magazine_count'] as int,
|
||||||
|
magazineCapacity: map['magazine_capacity'] as int,
|
||||||
|
notes: map['notes'] as String?,
|
||||||
|
createdAt: DateTime.parse(map['created_at'] as String),
|
||||||
|
optic: map['optic'] as String?,
|
||||||
|
silencer: map['silencer'] as String?,
|
||||||
|
trigger: map['trigger'] as String?,
|
||||||
|
customName: map['custom_name'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import 'package:path/path.dart' as path;
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../database/database_helper.dart';
|
import '../database/database_helper.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
|
import '../models/target_analysis.dart';
|
||||||
import '../models/shot.dart';
|
import '../models/shot.dart';
|
||||||
import '../models/target_type.dart';
|
import '../models/weapon.dart';
|
||||||
|
import '../models/maintenance.dart';
|
||||||
|
|
||||||
class SessionRepository {
|
class SessionRepository {
|
||||||
final DatabaseHelper _databaseHelper;
|
final DatabaseHelper _databaseHelper;
|
||||||
@@ -18,8 +20,38 @@ class SessionRepository {
|
|||||||
_uuid = uuid ?? const Uuid();
|
_uuid = uuid ?? const Uuid();
|
||||||
|
|
||||||
Future<Session> createSession({
|
Future<Session> createSession({
|
||||||
required TargetType targetType,
|
String? id,
|
||||||
|
required String weapon,
|
||||||
|
String? weaponId,
|
||||||
|
required int maxShotsPerTarget,
|
||||||
|
required List<TargetAnalysis> analyses,
|
||||||
|
String? notes,
|
||||||
|
int distance = 25,
|
||||||
|
DateTime? date,
|
||||||
|
}) async {
|
||||||
|
final session = Session(
|
||||||
|
id: id ?? _uuid.v4(),
|
||||||
|
weapon: weapon,
|
||||||
|
weaponId: weaponId,
|
||||||
|
maxShotsPerTarget: maxShotsPerTarget,
|
||||||
|
analyses: analyses,
|
||||||
|
createdAt: date ?? DateTime.now(),
|
||||||
|
notes: notes,
|
||||||
|
distance: distance,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _databaseHelper.insertSession(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addAnalysisToSession(String sessionId, TargetAnalysis analysis) async {
|
||||||
|
await _databaseHelper.insertTargetAnalysis(analysis, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TargetAnalysis> prepareTargetAnalysis({
|
||||||
required String imagePath,
|
required String imagePath,
|
||||||
|
required String sessionId,
|
||||||
|
required dynamic targetType, // TargetType
|
||||||
required List<Shot> shots,
|
required List<Shot> shots,
|
||||||
required int totalScore,
|
required int totalScore,
|
||||||
double? groupingDiameter,
|
double? groupingDiameter,
|
||||||
@@ -33,8 +65,9 @@ class SessionRepository {
|
|||||||
// Copy image to app documents directory
|
// Copy image to app documents directory
|
||||||
final savedImagePath = await _saveImage(imagePath);
|
final savedImagePath = await _saveImage(imagePath);
|
||||||
|
|
||||||
final session = Session(
|
return TargetAnalysis(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
|
sessionId: sessionId,
|
||||||
targetType: targetType,
|
targetType: targetType,
|
||||||
imagePath: savedImagePath,
|
imagePath: savedImagePath,
|
||||||
shots: shots,
|
shots: shots,
|
||||||
@@ -48,9 +81,6 @@ class SessionRepository {
|
|||||||
targetCenterY: targetCenterY,
|
targetCenterY: targetCenterY,
|
||||||
targetRadius: targetRadius,
|
targetRadius: targetRadius,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _databaseHelper.insertSession(session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> _saveImage(String sourcePath) async {
|
Future<String> _saveImage(String sourcePath) async {
|
||||||
@@ -75,30 +105,25 @@ class SessionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Session>> getAllSessions({
|
Future<List<Session>> getAllSessions({
|
||||||
TargetType? targetType,
|
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
}) async {
|
}) async {
|
||||||
return await _databaseHelper.getAllSessions(
|
return await _databaseHelper.getAllSessions(
|
||||||
targetType: targetType?.name,
|
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateSession(Session session) async {
|
|
||||||
await _databaseHelper.updateSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteSession(String id) async {
|
Future<void> deleteSession(String id) async {
|
||||||
final session = await getSession(id);
|
final session = await getSession(id);
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
// Delete the image file
|
for (final analysis in session.analyses) {
|
||||||
final imageFile = File(session.imagePath);
|
final imageFile = File(analysis.imagePath);
|
||||||
if (await imageFile.exists()) {
|
if (await imageFile.exists()) {
|
||||||
await imageFile.delete();
|
await imageFile.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await _databaseHelper.deleteSession(id);
|
await _databaseHelper.deleteSession(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +131,122 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getStatistics();
|
return await _databaseHelper.getStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
String generateShotId() {
|
/// Enregistre une session déjà construite (import de sauvegarde).
|
||||||
|
/// Les identifiants existants sont écrasés : réimporter deux fois la même
|
||||||
|
/// sauvegarde ne crée pas de doublons.
|
||||||
|
Future<void> saveSession(Session session) async {
|
||||||
|
await _databaseHelper.insertSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copie une image dans le dossier des cibles de l'app (import de sauvegarde).
|
||||||
|
Future<String> saveImageBytes(List<int> bytes, String extension) async {
|
||||||
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
final imagesDir = Directory(path.join(appDir.path, 'target_images'));
|
||||||
|
|
||||||
|
if (!await imagesDir.exists()) {
|
||||||
|
await imagesDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final fileName = '${_uuid.v4()}$extension';
|
||||||
|
final destPath = path.join(imagesDir.path, fileName);
|
||||||
|
await File(destPath).writeAsBytes(bytes);
|
||||||
|
|
||||||
|
return destPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
String generateId() {
|
||||||
return _uuid.v4();
|
return _uuid.v4();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weapon Repository methods
|
||||||
|
Future<Weapon> addWeapon({
|
||||||
|
required String name,
|
||||||
|
required WeaponType type,
|
||||||
|
required String caliber,
|
||||||
|
required int magazineCount,
|
||||||
|
required int magazineCapacity,
|
||||||
|
String? notes,
|
||||||
|
String? optic,
|
||||||
|
String? silencer,
|
||||||
|
String? trigger,
|
||||||
|
String? customName,
|
||||||
|
}) async {
|
||||||
|
final weapon = Weapon(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
caliber: caliber,
|
||||||
|
magazineCount: magazineCount,
|
||||||
|
magazineCapacity: magazineCapacity,
|
||||||
|
notes: notes,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
optic: optic,
|
||||||
|
silencer: silencer,
|
||||||
|
trigger: trigger,
|
||||||
|
customName: customName,
|
||||||
|
);
|
||||||
|
await _databaseHelper.insertWeapon(weapon);
|
||||||
|
return weapon;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Weapon>> getWeapons() async {
|
||||||
|
return await _databaseHelper.getAllWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistre une arme déjà construite (import de sauvegarde).
|
||||||
|
Future<void> saveWeapon(Weapon weapon) async {
|
||||||
|
await _databaseHelper.insertWeapon(weapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateWeapon(Weapon weapon) async {
|
||||||
|
await _databaseHelper.updateWeapon(weapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteWeapon(String id) async {
|
||||||
|
await _databaseHelper.deleteWeapon(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getRoundsFiredForWeapon(String weaponId) async {
|
||||||
|
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getSessionCountForWeapon(String weaponId) async {
|
||||||
|
return await _databaseHelper.getSessionCountForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addMaintenanceEntry({
|
||||||
|
required String weaponId,
|
||||||
|
required MaintenanceType type,
|
||||||
|
required String description,
|
||||||
|
int? roundsSinceLast,
|
||||||
|
DateTime? date,
|
||||||
|
}) async {
|
||||||
|
final entry = MaintenanceEntry(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
weaponId: weaponId,
|
||||||
|
type: type,
|
||||||
|
description: description,
|
||||||
|
date: date ?? DateTime.now(),
|
||||||
|
roundsSinceLastMaintenance: roundsSinceLast,
|
||||||
|
);
|
||||||
|
await _databaseHelper.insertMaintenance(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MaintenanceEntry>> getMaintenanceHistory(String weaponId) async {
|
||||||
|
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tout l'historique de maintenance de l'armurerie (export de sauvegarde).
|
||||||
|
Future<List<MaintenanceEntry>> getAllMaintenance() async {
|
||||||
|
return await _databaseHelper.getAllMaintenance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistre une entrée de maintenance déjà construite (import).
|
||||||
|
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
|
||||||
|
await _databaseHelper.insertMaintenance(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMaintenanceEntry(String id) async {
|
||||||
|
await _databaseHelper.deleteMaintenance(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,51 @@
|
|||||||
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
|
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
|
||||||
///
|
///
|
||||||
/// Gère le workflow complet d'analyse : chargement d'image, détection de cible,
|
/// Gère le workflow complet d'analyse : chargement d'image, gestion des
|
||||||
/// gestion des impacts (manuels et automatiques), calcul des scores,
|
/// impacts placés manuellement, calcul des scores, analyse de groupement
|
||||||
/// analyse de groupement et sauvegarde des sessions.
|
/// et sauvegarde des sessions.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../../data/models/session.dart';
|
import '../../data/models/target_analysis.dart';
|
||||||
import '../../data/models/shot.dart';
|
import '../../data/models/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/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 Uuid _uuid = const Uuid();
|
final Uuid _uuid = const Uuid();
|
||||||
|
|
||||||
AnalysisProvider({
|
AnalysisProvider({
|
||||||
required TargetDetectionService detectionService,
|
|
||||||
required ScoreCalculatorService scoreCalculatorService,
|
required ScoreCalculatorService scoreCalculatorService,
|
||||||
required GroupingAnalyzerService groupingAnalyzerService,
|
required GroupingAnalyzerService groupingAnalyzerService,
|
||||||
required SessionRepository sessionRepository,
|
required SessionRepository sessionRepository,
|
||||||
DistortionCorrectionService? distortionService,
|
}) : _scoreCalculatorService = scoreCalculatorService,
|
||||||
}) : _detectionService = detectionService,
|
|
||||||
_scoreCalculatorService = scoreCalculatorService,
|
|
||||||
_groupingAnalyzerService = groupingAnalyzerService,
|
_groupingAnalyzerService = groupingAnalyzerService,
|
||||||
_sessionRepository = sessionRepository,
|
_sessionRepository = sessionRepository;
|
||||||
_distortionService = distortionService ?? DistortionCorrectionService();
|
|
||||||
|
|
||||||
AnalysisState _state = AnalysisState.initial;
|
AnalysisState _state = AnalysisState.initial;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
String? _imagePath;
|
String? _imagePath;
|
||||||
TargetType? _targetType;
|
TargetType? _targetType;
|
||||||
|
|
||||||
// Target detection results
|
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
|
||||||
|
double _cropRotation = 0.0;
|
||||||
|
|
||||||
|
// Target calibration
|
||||||
double _targetCenterX = 0.5;
|
double _targetCenterX = 0.5;
|
||||||
double _targetCenterY = 0.5;
|
double _targetCenterY = 0.5;
|
||||||
double _targetRadius = 0.4;
|
double _targetRadius = 0.4;
|
||||||
|
double _targetInnerRadius = 0.04;
|
||||||
int _ringCount = 10;
|
int _ringCount = 10;
|
||||||
List<double>? _ringRadii; // Individual ring radii multipliers
|
List<double>? _ringRadii; // Individual ring radii multipliers
|
||||||
double _imageAspectRatio = 1.0; // width / height
|
double _imageAspectRatio = 1.0; // width / height
|
||||||
@@ -62,23 +59,16 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
// Grouping results
|
// Grouping results
|
||||||
GroupingResult? _groupingResult;
|
GroupingResult? _groupingResult;
|
||||||
|
|
||||||
// Reference-based detection
|
|
||||||
List<Shot> _referenceImpacts = [];
|
|
||||||
ImpactCharacteristics? _learnedCharacteristics;
|
|
||||||
|
|
||||||
// Distortion correction
|
|
||||||
bool _distortionCorrectionEnabled = false;
|
|
||||||
DistortionParameters? _distortionParams;
|
|
||||||
String? _correctedImagePath;
|
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
AnalysisState get state => _state;
|
AnalysisState get state => _state;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
String? get imagePath => _imagePath;
|
String? get imagePath => _imagePath;
|
||||||
TargetType? get targetType => _targetType;
|
TargetType? get targetType => _targetType;
|
||||||
|
double get cropRotation => _cropRotation; // Getter pour le Plotting
|
||||||
double get targetCenterX => _targetCenterX;
|
double get targetCenterX => _targetCenterX;
|
||||||
double get targetCenterY => _targetCenterY;
|
double get targetCenterY => _targetCenterY;
|
||||||
double get targetRadius => _targetRadius;
|
double get targetRadius => _targetRadius;
|
||||||
|
double get targetInnerRadius => _targetInnerRadius;
|
||||||
int get ringCount => _ringCount;
|
int get ringCount => _ringCount;
|
||||||
List<double>? get ringRadii =>
|
List<double>? get ringRadii =>
|
||||||
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
|
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
|
||||||
@@ -89,30 +79,19 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
int get totalScore => _scoreResult?.totalScore ?? 0;
|
int get totalScore => _scoreResult?.totalScore ?? 0;
|
||||||
int get shotCount => _shots.length;
|
int get shotCount => _shots.length;
|
||||||
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
|
|
||||||
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
|
|
||||||
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
|
|
||||||
|
|
||||||
// Distortion correction getters
|
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
||||||
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
|
void setCropRotation(double rotation) {
|
||||||
DistortionParameters? get distortionParams => _distortionParams;
|
_cropRotation = rotation;
|
||||||
String? get correctedImagePath => _correctedImagePath;
|
notifyListeners();
|
||||||
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
|
}
|
||||||
|
|
||||||
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
|
/// Charge l'image et initialise les paramètres de cible par défaut.
|
||||||
String? get displayImagePath =>
|
/// Le placement des impacts et la calibration se font ensuite manuellement.
|
||||||
_distortionCorrectionEnabled && _correctedImagePath != null
|
|
||||||
? _correctedImagePath
|
|
||||||
: _imagePath;
|
|
||||||
|
|
||||||
/// Analyze an image
|
|
||||||
///
|
|
||||||
/// [autoAnalyze] determines if we should run automatic detection immediately.
|
|
||||||
/// If false, only the image is loaded and default target parameters are set.
|
|
||||||
Future<void> analyzeImage(
|
Future<void> analyzeImage(
|
||||||
String imagePath,
|
String imagePath,
|
||||||
TargetType targetType, {
|
TargetType targetType, {
|
||||||
bool autoAnalyze = true,
|
Offset? manualCenter,
|
||||||
}) async {
|
}) async {
|
||||||
_state = AnalysisState.loading;
|
_state = AnalysisState.loading;
|
||||||
_imagePath = imagePath;
|
_imagePath = imagePath;
|
||||||
@@ -129,51 +108,13 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_imageAspectRatio = frame.image.width / frame.image.height;
|
_imageAspectRatio = frame.image.width / frame.image.height;
|
||||||
frame.image.dispose();
|
frame.image.dispose();
|
||||||
|
|
||||||
if (!autoAnalyze) {
|
_targetCenterX = manualCenter?.dx ?? 0.5;
|
||||||
// Just setup default values without running detection
|
_targetCenterY = manualCenter?.dy ?? 0.5;
|
||||||
_targetCenterX = 0.5;
|
|
||||||
_targetCenterY = 0.5;
|
|
||||||
_targetRadius = 0.4;
|
_targetRadius = 0.4;
|
||||||
|
_targetInnerRadius = 0.04;
|
||||||
|
|
||||||
// Initialize empty shots list
|
|
||||||
_shots = [];
|
_shots = [];
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
|
||||||
notifyListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect target and impacts
|
|
||||||
final result = _detectionService.detectTarget(imagePath, targetType);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
_state = AnalysisState.error;
|
|
||||||
_errorMessage = result.errorMessage;
|
|
||||||
notifyListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_targetCenterX = result.centerX;
|
|
||||||
_targetCenterY = result.centerY;
|
|
||||||
_targetRadius = result.radius;
|
|
||||||
|
|
||||||
// Create shots from detected impacts
|
|
||||||
_shots = result.impacts.map((impact) {
|
|
||||||
return Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: impact.suggestedScore,
|
|
||||||
sessionId: '', // Will be set when saving
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Calculate scores
|
|
||||||
_recalculateScores();
|
|
||||||
|
|
||||||
// Calculate grouping
|
|
||||||
_recalculateGrouping();
|
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
_state = AnalysisState.success;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -186,7 +127,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
/// Add a manual shot
|
/// Add a manual shot
|
||||||
void addShot(double x, double y) {
|
void addShot(double x, double y) {
|
||||||
final score = _calculateShotScore(x, y);
|
final score = _calculateShotScore(x, y);
|
||||||
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
|
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
|
||||||
|
|
||||||
_shots.add(shot);
|
_shots.add(shot);
|
||||||
_recalculateScores();
|
_recalculateScores();
|
||||||
@@ -194,6 +135,15 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Synthèse).
|
||||||
|
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
|
||||||
|
void clearShots() {
|
||||||
|
_shots.clear();
|
||||||
|
_recalculateScores();
|
||||||
|
_recalculateGrouping();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a shot
|
/// Remove a shot
|
||||||
void removeShot(String shotId) {
|
void removeShot(String shotId) {
|
||||||
_shots.removeWhere((shot) => shot.id == shotId);
|
_shots.removeWhere((shot) => shot.id == shotId);
|
||||||
@@ -202,301 +152,38 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a shot to a new position
|
/// Update a shot's score manually
|
||||||
void moveShot(String shotId, double newX, double newY) {
|
void updateShotScore(String shotId, int newScore) {
|
||||||
final index = _shots.indexWhere((shot) => shot.id == shotId);
|
final index = _shots.indexWhere((shot) => shot.id == shotId);
|
||||||
if (index == -1) return;
|
if (index == -1) return;
|
||||||
|
|
||||||
final newScore = _calculateShotScore(newX, newY);
|
_shots[index] = _shots[index].copyWith(score: newScore);
|
||||||
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
|
|
||||||
|
|
||||||
_recalculateScores();
|
_recalculateScores();
|
||||||
_recalculateGrouping();
|
|
||||||
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,
|
|
||||||
sessionId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
|
|
||||||
///
|
|
||||||
/// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes
|
|
||||||
/// de compilation. Cette méthode retourne 0 (aucun impact détecté).
|
|
||||||
/// Utiliser autoDetectImpacts() à la place.
|
|
||||||
///
|
|
||||||
/// Utilise les algorithmes OpenCV pour une détection plus robuste:
|
|
||||||
/// - Transformation de Hough pour détecter les cercles
|
|
||||||
/// - Analyse de contours avec filtrage par circularité
|
|
||||||
Future<int> autoDetectImpactsWithOpenCV({
|
|
||||||
double cannyThreshold1 = 50,
|
|
||||||
double cannyThreshold2 = 150,
|
|
||||||
double minDist = 20,
|
|
||||||
double param1 = 100,
|
|
||||||
double param2 = 30,
|
|
||||||
int minRadius = 5,
|
|
||||||
int maxRadius = 50,
|
|
||||||
int blurSize = 5,
|
|
||||||
bool useContourDetection = true,
|
|
||||||
double minCircularity = 0.6,
|
|
||||||
double minContourArea = 50,
|
|
||||||
double maxContourArea = 5000,
|
|
||||||
bool clearExisting = false,
|
|
||||||
}) async {
|
|
||||||
if (_imagePath == null || _targetType == null) return 0;
|
|
||||||
|
|
||||||
final settings = OpenCVDetectionSettings(
|
|
||||||
cannyThreshold1: cannyThreshold1,
|
|
||||||
cannyThreshold2: cannyThreshold2,
|
|
||||||
minDist: minDist,
|
|
||||||
param1: param1,
|
|
||||||
param2: param2,
|
|
||||||
minRadius: minRadius,
|
|
||||||
maxRadius: maxRadius,
|
|
||||||
blurSize: blurSize,
|
|
||||||
useContourDetection: useContourDetection,
|
|
||||||
minCircularity: minCircularity,
|
|
||||||
minContourArea: minContourArea,
|
|
||||||
maxContourArea: maxContourArea,
|
|
||||||
);
|
|
||||||
|
|
||||||
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
|
|
||||||
_imagePath!,
|
|
||||||
_targetType!,
|
|
||||||
_targetCenterX,
|
|
||||||
_targetCenterY,
|
|
||||||
_targetRadius,
|
|
||||||
_ringCount,
|
|
||||||
settings: settings,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clearExisting) {
|
|
||||||
_shots.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detected impacts as shots
|
|
||||||
for (final impact in detectedImpacts) {
|
|
||||||
final score = _calculateShotScore(impact.x, impact.y);
|
|
||||||
final shot = Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: score,
|
|
||||||
sessionId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect impacts with OpenCV using reference points
|
|
||||||
Future<int> detectFromReferencesWithOpenCV({
|
|
||||||
double tolerance = 2.0,
|
|
||||||
bool clearExisting = false,
|
|
||||||
}) async {
|
|
||||||
if (_imagePath == null ||
|
|
||||||
_targetType == null ||
|
|
||||||
_referenceImpacts.length < 2) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convertir les références
|
|
||||||
final references = _referenceImpacts
|
|
||||||
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final detectedImpacts = _detectionService
|
|
||||||
.detectImpactsWithOpenCVFromReferences(
|
|
||||||
_imagePath!,
|
|
||||||
_targetType!,
|
|
||||||
_targetCenterX,
|
|
||||||
_targetCenterY,
|
|
||||||
_targetRadius,
|
|
||||||
_ringCount,
|
|
||||||
references,
|
|
||||||
tolerance: tolerance,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clearExisting) {
|
|
||||||
_shots.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detected impacts as shots
|
|
||||||
for (final impact in detectedImpacts) {
|
|
||||||
final score = _calculateShotScore(impact.x, impact.y);
|
|
||||||
final shot = Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: score,
|
|
||||||
sessionId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a reference impact for calibrated detection
|
|
||||||
void addReferenceImpact(double x, double y) {
|
|
||||||
final score = _calculateShotScore(x, y);
|
|
||||||
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
|
|
||||||
_referenceImpacts.add(shot);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a reference impact
|
|
||||||
void removeReferenceImpact(String shotId) {
|
|
||||||
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
|
|
||||||
_learnedCharacteristics = null;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all reference impacts
|
|
||||||
void clearReferenceImpacts() {
|
|
||||||
_referenceImpacts.clear();
|
|
||||||
_learnedCharacteristics = null;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Learn characteristics from reference impacts
|
|
||||||
bool learnFromReferences() {
|
|
||||||
if (_imagePath == null || _referenceImpacts.length < 2) return false;
|
|
||||||
|
|
||||||
final references = _referenceImpacts
|
|
||||||
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
|
|
||||||
_imagePath!,
|
|
||||||
references,
|
|
||||||
);
|
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
return _learnedCharacteristics != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto-detect impacts using learned reference characteristics
|
|
||||||
Future<int> detectFromReferences({
|
|
||||||
double tolerance = 2.0,
|
|
||||||
bool clearExisting = false,
|
|
||||||
}) async {
|
|
||||||
if (_imagePath == null ||
|
|
||||||
_targetType == null ||
|
|
||||||
_learnedCharacteristics == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
final detectedImpacts = _detectionService.detectImpactsFromReferences(
|
|
||||||
_imagePath!,
|
|
||||||
_targetType!,
|
|
||||||
_targetCenterX,
|
|
||||||
_targetCenterY,
|
|
||||||
_targetRadius,
|
|
||||||
_ringCount,
|
|
||||||
_learnedCharacteristics!,
|
|
||||||
tolerance: tolerance,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clearExisting) {
|
|
||||||
_shots.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detected impacts as shots
|
|
||||||
for (final impact in detectedImpacts) {
|
|
||||||
final score = _calculateShotScore(impact.x, impact.y);
|
|
||||||
final shot = Shot(
|
|
||||||
id: _uuid.v4(),
|
|
||||||
x: impact.x,
|
|
||||||
y: impact.y,
|
|
||||||
score: score,
|
|
||||||
sessionId: '',
|
|
||||||
);
|
|
||||||
_shots.add(shot);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recalculateScores();
|
|
||||||
_recalculateGrouping();
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
return detectedImpacts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjust target position
|
/// Adjust target position
|
||||||
void adjustTargetPosition(
|
void adjustTargetPosition(
|
||||||
double centerX,
|
double centerX,
|
||||||
double centerY,
|
double centerY,
|
||||||
|
double innerRadius,
|
||||||
double radius, {
|
double radius, {
|
||||||
int? ringCount,
|
int? ringCount,
|
||||||
List<double>? ringRadii,
|
List<double>? ringRadii,
|
||||||
|
double zoomScale = 1.0,
|
||||||
|
Offset offset = Offset.zero,
|
||||||
}) {
|
}) {
|
||||||
_targetCenterX = centerX;
|
_targetCenterX = (centerX - offset.dx) / zoomScale;
|
||||||
_targetCenterY = centerY;
|
_targetCenterY = (centerY - offset.dy) / zoomScale;
|
||||||
_targetRadius = radius;
|
_targetRadius = radius / zoomScale;
|
||||||
|
_targetInnerRadius = innerRadius / zoomScale;
|
||||||
|
|
||||||
if (ringCount != null) {
|
if (ringCount != null) {
|
||||||
_ringCount = ringCount;
|
_ringCount = ringCount;
|
||||||
}
|
}
|
||||||
if (ringRadii != null) {
|
|
||||||
|
// CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement !
|
||||||
_ringRadii = ringRadii;
|
_ringRadii = ringRadii;
|
||||||
}
|
|
||||||
|
|
||||||
// Recalculate all shot scores based on new target position
|
// Recalculate all shot scores based on new target position
|
||||||
_shots = _shots.map((shot) {
|
_shots = _shots.map((shot) {
|
||||||
@@ -508,84 +195,6 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
|
|
||||||
void calculateDistortion() {
|
|
||||||
_distortionParams = _distortionService.calculateDistortionFromCalibration(
|
|
||||||
targetCenterX: _targetCenterX,
|
|
||||||
targetCenterY: _targetCenterY,
|
|
||||||
targetRadius: _targetRadius,
|
|
||||||
imageAspectRatio: _imageAspectRatio,
|
|
||||||
);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applique la correction de distorsion à l'image
|
|
||||||
/// Crée une nouvelle image corrigée et la sauvegarde
|
|
||||||
Future<void> applyDistortionCorrection() async {
|
|
||||||
if (_imagePath == null || _distortionParams == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_correctedImagePath = await _distortionService.applyCorrection(
|
|
||||||
_imagePath!,
|
|
||||||
_distortionParams!,
|
|
||||||
);
|
|
||||||
_distortionCorrectionEnabled = true;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
_errorMessage = 'Erreur lors de la correction: $e';
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active ou désactive l'affichage de l'image corrigée
|
|
||||||
void setDistortionCorrectionEnabled(bool enabled) {
|
|
||||||
if (enabled && _correctedImagePath == null && _distortionParams != null) {
|
|
||||||
// Si on active mais pas encore d'image corrigée, la créer
|
|
||||||
applyDistortionCorrection();
|
|
||||||
} else {
|
|
||||||
_distortionCorrectionEnabled = enabled;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* version deux a tester*/
|
|
||||||
/// Calcule ET applique la correction pour un feedback immédiat
|
|
||||||
Future<void> calculateAndApplyDistortion() async {
|
|
||||||
// 1. Calcul des paramètres (votre code actuel)
|
|
||||||
_distortionParams = _distortionService.calculateDistortionFromCalibration(
|
|
||||||
targetCenterX: _targetCenterX,
|
|
||||||
targetCenterY: _targetCenterY,
|
|
||||||
targetRadius: _targetRadius,
|
|
||||||
imageAspectRatio: _imageAspectRatio,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Vérification si une correction est réellement nécessaire
|
|
||||||
if (_distortionParams != null && _distortionParams!.needsCorrection) {
|
|
||||||
// 3. Application immédiate de la transformation (méthode asynchrone)
|
|
||||||
await applyDistortionCorrection();
|
|
||||||
} else {
|
|
||||||
notifyListeners(); // On prévient quand même si pas de correction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> runFullDistortionWorkflow() async {
|
|
||||||
_state = AnalysisState.loading; // Affiche un spinner sur votre UI
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
calculateDistortion(); // Calcule les paramètres
|
|
||||||
await applyDistortionCorrection(); // Génère le fichier corrigé
|
|
||||||
_distortionCorrectionEnabled = true; // Active l'affichage
|
|
||||||
_state = AnalysisState.success;
|
|
||||||
} catch (e) {
|
|
||||||
_errorMessage = "Erreur de rendu : $e";
|
|
||||||
_state = AnalysisState.error;
|
|
||||||
} finally {
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* fin section deux a tester*/
|
|
||||||
|
|
||||||
int _calculateShotScore(double x, double y) {
|
int _calculateShotScore(double x, double y) {
|
||||||
if (_targetType == TargetType.concentric) {
|
if (_targetType == TargetType.concentric) {
|
||||||
return _scoreCalculatorService.calculateConcentricScore(
|
return _scoreCalculatorService.calculateConcentricScore(
|
||||||
@@ -629,16 +238,71 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
|
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exporte l'image et le json vers le backend IA.
|
||||||
|
/// [sessionId], [distance] et [caliber] proviennent du SessionProvider de
|
||||||
|
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
|
||||||
|
///
|
||||||
|
/// Le nom de l'arme n'est volontairement pas transmis : il est souvent
|
||||||
|
/// personnalisé par l'utilisateur et n'apporte rien au modèle de détection.
|
||||||
|
Future<AiExportResult> exportToAiBackend({
|
||||||
|
String? sessionId,
|
||||||
|
int? distance,
|
||||||
|
String? caliber,
|
||||||
|
int? expectedShots,
|
||||||
|
}) async {
|
||||||
|
if (_imagePath == null || _targetType == null) {
|
||||||
|
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
|
||||||
|
notifyListeners();
|
||||||
|
return AiExportResult.error(
|
||||||
|
code: 'MISSING_DATA',
|
||||||
|
message: "Impossible d'exporter : image ou type de cible manquant.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final service = AiExportService();
|
||||||
|
|
||||||
|
_state = AnalysisState.loading;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final result = await service.exportData(
|
||||||
|
imagePath: _imagePath!,
|
||||||
|
sessionId: sessionId ?? 'export',
|
||||||
|
targetType: _targetType!,
|
||||||
|
targetCenterX: _targetCenterX,
|
||||||
|
targetCenterY: _targetCenterY,
|
||||||
|
targetRadius: _targetRadius,
|
||||||
|
shots: _shots,
|
||||||
|
distanceMeters: distance ?? 25,
|
||||||
|
caliber: caliber ?? 'unknown',
|
||||||
|
expectedShots: expectedShots,
|
||||||
|
);
|
||||||
|
|
||||||
|
_state = AnalysisState.success;
|
||||||
|
if (!result.isSuccess) {
|
||||||
|
_errorMessage = result.message;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Save the session
|
/// Save the session
|
||||||
Future<Session> saveSession({String? notes}) async {
|
Future<TargetAnalysis> saveSession({
|
||||||
|
String? notes,
|
||||||
|
String? sessionId,
|
||||||
|
String? weaponName,
|
||||||
|
String? weaponId,
|
||||||
|
int? distance,
|
||||||
|
DateTime? date,
|
||||||
|
}) async {
|
||||||
if (_imagePath == null || _targetType == null) {
|
if (_imagePath == null || _targetType == null) {
|
||||||
throw Exception('Cannot save: missing image or target type');
|
throw Exception('Cannot save: missing image or target type');
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = await _sessionRepository.createSession(
|
final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
|
||||||
targetType: _targetType!,
|
|
||||||
imagePath: _imagePath!,
|
imagePath: _imagePath!,
|
||||||
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(),
|
sessionId: sessionId ?? 'standalone',
|
||||||
|
targetType: _targetType!,
|
||||||
|
shots: _shots,
|
||||||
totalScore: totalScore,
|
totalScore: totalScore,
|
||||||
groupingDiameter: _groupingResult?.diameter,
|
groupingDiameter: _groupingResult?.diameter,
|
||||||
groupingCenterX: _groupingResult?.centerX,
|
groupingCenterX: _groupingResult?.centerX,
|
||||||
@@ -649,11 +313,41 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
targetRadius: _targetRadius,
|
targetRadius: _targetRadius,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update shots with session ID
|
if (sessionId != null && sessionId != 'standalone') {
|
||||||
_shots = session.shots;
|
final existingSession = await _sessionRepository.getSession(sessionId);
|
||||||
notifyListeners();
|
if (existingSession != null) {
|
||||||
|
await _sessionRepository.addAnalysisToSession(
|
||||||
|
sessionId,
|
||||||
|
targetAnalysis,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// CORRECTION : Utilise la date injectée plutôt que DateTime.now()
|
||||||
|
await _sessionRepository.createSession(
|
||||||
|
id: sessionId,
|
||||||
|
weapon: weaponName ?? 'Inconnue',
|
||||||
|
weaponId: weaponId,
|
||||||
|
maxShotsPerTarget: shotCount,
|
||||||
|
analyses: [targetAnalysis],
|
||||||
|
notes: notes,
|
||||||
|
distance: distance ?? 25,
|
||||||
|
date: date ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// CORRECTION : S'applique aussi au flux alternatif sans ID préalable
|
||||||
|
await _sessionRepository.createSession(
|
||||||
|
weapon: weaponName ?? 'Inconnue',
|
||||||
|
weaponId: weaponId,
|
||||||
|
maxShotsPerTarget: shotCount,
|
||||||
|
analyses: [targetAnalysis],
|
||||||
|
notes: notes,
|
||||||
|
distance: distance ?? 25,
|
||||||
|
date: date ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return session;
|
notifyListeners();
|
||||||
|
return targetAnalysis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the provider
|
/// Reset the provider
|
||||||
@@ -662,20 +356,36 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_imagePath = null;
|
_imagePath = null;
|
||||||
_targetType = null;
|
_targetType = null;
|
||||||
|
_cropRotation = 0.0;
|
||||||
_targetCenterX = 0.5;
|
_targetCenterX = 0.5;
|
||||||
_targetCenterY = 0.5;
|
_targetCenterY = 0.5;
|
||||||
_targetRadius = 0.4;
|
_targetRadius = 0.4;
|
||||||
|
_targetInnerRadius = 0.04;
|
||||||
_ringCount = 10;
|
_ringCount = 10;
|
||||||
_ringRadii = null;
|
_ringRadii = null;
|
||||||
_imageAspectRatio = 1.0;
|
_imageAspectRatio = 1.0;
|
||||||
_shots = [];
|
_shots = [];
|
||||||
_scoreResult = null;
|
_scoreResult = null;
|
||||||
_groupingResult = null;
|
_groupingResult = null;
|
||||||
_referenceImpacts = [];
|
notifyListeners();
|
||||||
_learnedCharacteristics = null;
|
}
|
||||||
_distortionCorrectionEnabled = false;
|
|
||||||
_distortionParams = null;
|
/// Met à jour la position d'un impact de tir en direct (Glisser-Déposer).
|
||||||
_correctedImagePath = null;
|
void updateShotPosition(String shotId, double newX, double newY) {
|
||||||
|
final index = _shots.indexWhere((s) => s.id == shotId);
|
||||||
|
if (index != -1) {
|
||||||
|
// 1. On calcule dynamiquement le nouveau score de la zone survolée
|
||||||
|
final newScore = _calculateShotScore(newX, newY);
|
||||||
|
|
||||||
|
// 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés
|
||||||
|
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
|
||||||
|
|
||||||
|
// 3. On applique les deux méthodes natives de recalcul de ton application
|
||||||
|
_recalculateScores();
|
||||||
|
_recalculateGrouping();
|
||||||
|
|
||||||
|
// 4. On demande à l'écran de se redessiner en direct
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/// É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 '../../services/tutorial_service.dart';
|
||||||
|
import '../tutorial/tutorial_coach.dart';
|
||||||
|
import '../tutorial/tutorial_step.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;
|
||||||
|
|
||||||
|
// Clés du didacticiel : zone de travail et bouton de validation.
|
||||||
|
final GlobalKey _tutoCanvasKey = GlobalKey();
|
||||||
|
final GlobalKey _tutoValidateKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_transformationController.addListener(_onTransformChanged);
|
||||||
|
// Première ouverture de l'éditeur : on montre les gestes disponibles.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
TutorialCoach.maybeStart(
|
||||||
|
context,
|
||||||
|
tourId: TutorialTours.impactEditor,
|
||||||
|
stepsBuilder: _buildEditorTutorialSteps,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TutorialStep> _buildEditorTutorialSteps() => [
|
||||||
|
TutorialStep(
|
||||||
|
targetKey: _tutoCanvasKey,
|
||||||
|
title: 'Ajouter un impact',
|
||||||
|
description:
|
||||||
|
'Touchez la cible à l\'endroit de l\'impact : il est ajouté '
|
||||||
|
'immédiatement, même collé à un impact déjà placé. Le score est '
|
||||||
|
'calculé automatiquement selon la zone touchée.',
|
||||||
|
gesture: TutorialGesture.tap,
|
||||||
|
icon: Icons.add_location_alt_outlined,
|
||||||
|
spotPadding: 0,
|
||||||
|
),
|
||||||
|
TutorialStep(
|
||||||
|
targetKey: _tutoCanvasKey,
|
||||||
|
title: 'Déplacer un impact',
|
||||||
|
description:
|
||||||
|
'Appui long sur un impact, puis glissez le doigt pour l\'ajuster '
|
||||||
|
'au millimètre. L\'impact reste visible au-dessus du doigt.',
|
||||||
|
gesture: TutorialGesture.drag,
|
||||||
|
icon: Icons.open_with,
|
||||||
|
spotPadding: 0,
|
||||||
|
),
|
||||||
|
TutorialStep(
|
||||||
|
targetKey: _tutoCanvasKey,
|
||||||
|
title: 'Zoomer sur la cible',
|
||||||
|
description:
|
||||||
|
'Écartez deux doigts pour zoomer (jusqu\'à 12×) et placer vos '
|
||||||
|
'impacts avec précision ; rapprochez-les pour dézoomer. À un '
|
||||||
|
'doigt, vous faites glisser l\'image.',
|
||||||
|
gesture: TutorialGesture.pinch,
|
||||||
|
icon: Icons.zoom_in,
|
||||||
|
spotPadding: 0,
|
||||||
|
),
|
||||||
|
TutorialStep(
|
||||||
|
targetKey: _tutoValidateKey,
|
||||||
|
title: 'Valider vos impacts',
|
||||||
|
description:
|
||||||
|
'VALIDER renvoie vers la synthèse avec les scores et le '
|
||||||
|
'groupement. La corbeille, à gauche, efface tous les impacts '
|
||||||
|
'sans toucher à la calibration.',
|
||||||
|
gesture: TutorialGesture.tap,
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
spotPadding: 8,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@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(
|
||||||
|
key: _tutoValidateKey,
|
||||||
|
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(
|
||||||
|
key: _tutoCanvasKey,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
transformationController: _transformationController,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 12.0,
|
||||||
|
boundaryMargin: const EdgeInsets.all(80),
|
||||||
|
panEnabled: _movingShotId == null,
|
||||||
|
child: Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
// TAP : ajoute un impact, sans exception. Même collé à un
|
||||||
|
// impact existant, le tap crée le nouvel impact au lieu
|
||||||
|
// d'ouvrir l'édition du score.
|
||||||
|
onTapUp: (details) {
|
||||||
|
if (_movingShotId != null) return;
|
||||||
|
final rel = _toImageRelative(details.globalPosition);
|
||||||
|
if (rel == null) return;
|
||||||
|
provider.addShot(rel.dx, rel.dy);
|
||||||
|
},
|
||||||
|
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
|
||||||
|
onLongPressStart: (details) {
|
||||||
|
final rel = _toImageRelative(details.globalPosition);
|
||||||
|
if (rel == null) return;
|
||||||
|
final hit = _hitTestShot(provider, rel);
|
||||||
|
if (hit != null) {
|
||||||
|
setState(() => _movingShotId = hit.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPressMoveUpdate: (details) {
|
||||||
|
if (_movingShotId == null) return;
|
||||||
|
// Décalage pour que l'impact reste visible au-dessus du doigt.
|
||||||
|
final adjusted =
|
||||||
|
details.globalPosition + const Offset(-25, -35);
|
||||||
|
final rel = _toImageRelative(adjusted);
|
||||||
|
if (rel == null) return;
|
||||||
|
provider.updateShotPosition(
|
||||||
|
_movingShotId!, rel.dx, rel.dy);
|
||||||
|
},
|
||||||
|
onLongPressEnd: (_) {
|
||||||
|
if (_movingShotId != null) {
|
||||||
|
setState(() => _movingShotId = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Image.file(
|
||||||
|
File(provider.imagePath!),
|
||||||
|
key: _imageKey,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: TargetOverlay(
|
||||||
|
targetCenterX: provider.targetCenterX,
|
||||||
|
targetCenterY: provider.targetCenterY,
|
||||||
|
targetRadius: provider.targetRadius,
|
||||||
|
targetType: provider.targetType!,
|
||||||
|
shots: provider.shots,
|
||||||
|
showRings: true,
|
||||||
|
zoomScale: _currentZoomScale,
|
||||||
|
// Aucun onShotTapped : les impacts ne captent plus le
|
||||||
|
// toucher, tout va au GestureDetector parent qui
|
||||||
|
// ajoute un impact (y compris pile sur un impact
|
||||||
|
// existant).
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/metric_info_button.dart';
|
||||||
import '../../../services/grouping_analyzer_service.dart';
|
import '../../../services/grouping_analyzer_service.dart';
|
||||||
|
|
||||||
class GroupingStats extends StatelessWidget {
|
class GroupingStats extends StatelessWidget {
|
||||||
@@ -43,32 +44,65 @@ class GroupingStats extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const MetricInfoButton(
|
||||||
|
title: 'Groupement',
|
||||||
|
explanations: [
|
||||||
|
MetricExplanation(
|
||||||
|
'Étalement',
|
||||||
|
'Distance entre vos deux impacts les plus éloignés, '
|
||||||
|
'exprimée en % de la largeur de l\'image. Plus c\'est '
|
||||||
|
'bas, plus le groupement est serré.',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'Dispersion',
|
||||||
|
'Régularité des impacts autour de leur centre commun '
|
||||||
|
'(écart-type). Plus c\'est bas, plus vos tirs sont '
|
||||||
|
'réguliers.',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'Décalage',
|
||||||
|
'Direction du centre de votre groupement par rapport au '
|
||||||
|
'centre de la cible (ex. « Droite » = vos tirs sont '
|
||||||
|
'globalement décalés vers la droite).',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'Étoiles',
|
||||||
|
'Qualité globale du groupement, de ★ (à améliorer) à '
|
||||||
|
'★★★★★ (excellent).',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_buildQualityBadge(context),
|
_buildQualityBadge(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
children: [
|
||||||
_buildStat(
|
Expanded(
|
||||||
|
child: _buildStat(
|
||||||
context,
|
context,
|
||||||
'Diametre',
|
'Étalement',
|
||||||
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
|
||||||
icon: Icons.straighten,
|
icon: Icons.straighten,
|
||||||
),
|
),
|
||||||
_buildStat(
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStat(
|
||||||
context,
|
context,
|
||||||
'Dispersion',
|
'Dispersion',
|
||||||
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
|
||||||
icon: Icons.scatter_plot,
|
icon: Icons.scatter_plot,
|
||||||
),
|
),
|
||||||
_buildStat(
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStat(
|
||||||
context,
|
context,
|
||||||
'Decalage',
|
'Décalage',
|
||||||
offsetDescription,
|
offsetDescription,
|
||||||
icon: Icons.compare_arrows,
|
icon: Icons.compare_arrows,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -125,12 +159,14 @@ class GroupingStats extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -252,22 +288,22 @@ class GroupingStats extends StatelessWidget {
|
|||||||
|
|
||||||
String _getOffsetDescription(double offsetX, double offsetY) {
|
String _getOffsetDescription(double offsetX, double offsetY) {
|
||||||
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
||||||
return 'Centre';
|
return 'Centré';
|
||||||
}
|
}
|
||||||
|
|
||||||
String vertical = '';
|
String vertical = '';
|
||||||
String horizontal = '';
|
String horizontal = '';
|
||||||
|
|
||||||
if (offsetY < -0.02) {
|
if (offsetY < -0.02) {
|
||||||
vertical = 'H';
|
vertical = 'Haut';
|
||||||
} else if (offsetY > 0.02) {
|
} else if (offsetY > 0.02) {
|
||||||
vertical = 'B';
|
vertical = 'Bas';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offsetX < -0.02) {
|
if (offsetX < -0.02) {
|
||||||
horizontal = 'G';
|
horizontal = 'Gauche';
|
||||||
} else if (offsetX > 0.02) {
|
} else if (offsetX > 0.02) {
|
||||||
horizontal = 'D';
|
horizontal = 'Droite';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/constants/app_constants.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/metric_info_button.dart';
|
||||||
import '../../../data/models/target_type.dart';
|
import '../../../data/models/target_type.dart';
|
||||||
import '../../../services/score_calculator_service.dart';
|
import '../../../services/score_calculator_service.dart';
|
||||||
|
|
||||||
@@ -16,12 +17,22 @@ class ScoreCard extends StatelessWidget {
|
|||||||
final ScoreResult? scoreResult;
|
final ScoreResult? scoreResult;
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
|
|
||||||
|
/// Score cumulé de la session (cibles déjà validées + cible en cours).
|
||||||
|
///
|
||||||
|
/// null hors session : le bandeau de session n'est alors pas affiché.
|
||||||
|
final int? sessionTotalScore;
|
||||||
|
|
||||||
|
/// Nombre de cibles comptabilisées dans [sessionTotalScore].
|
||||||
|
final int sessionTargetCount;
|
||||||
|
|
||||||
const ScoreCard({
|
const ScoreCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.totalScore,
|
required this.totalScore,
|
||||||
required this.shotCount,
|
required this.shotCount,
|
||||||
this.scoreResult,
|
this.scoreResult,
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
|
this.sessionTotalScore,
|
||||||
|
this.sessionTargetCount = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,6 +55,41 @@ class ScoreCard extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (sessionTotalScore != null) ...[
|
||||||
|
Flexible(child: _buildSessionBadge(context)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
MetricInfoButton(
|
||||||
|
title: 'Score',
|
||||||
|
explanations: [
|
||||||
|
MetricExplanation(
|
||||||
|
'Total',
|
||||||
|
'Somme des points de tous vos impacts sur CETTE cible, '
|
||||||
|
'sur le maximum possible (nombre d\'impacts × '
|
||||||
|
'$maxScore points).',
|
||||||
|
),
|
||||||
|
const MetricExplanation(
|
||||||
|
'Session',
|
||||||
|
'Score cumulé de toutes les cibles de la session en '
|
||||||
|
'cours, cible affichée comprise.',
|
||||||
|
),
|
||||||
|
const MetricExplanation(
|
||||||
|
'Impacts',
|
||||||
|
'Nombre de tirs détectés sur la cible.',
|
||||||
|
),
|
||||||
|
MetricExplanation(
|
||||||
|
'Moyenne',
|
||||||
|
'Points marqués en moyenne par impact, sur $maxScore.',
|
||||||
|
),
|
||||||
|
const MetricExplanation(
|
||||||
|
'Réussite',
|
||||||
|
'Votre score exprimé en pourcentage du score maximum '
|
||||||
|
'possible. C\'est une mesure du résultat, pas de la '
|
||||||
|
'régularité des tirs.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
@@ -67,11 +113,12 @@ class ScoreCard extends StatelessWidget {
|
|||||||
shotCount > 0
|
shotCount > 0
|
||||||
? (totalScore / shotCount).toStringAsFixed(1)
|
? (totalScore / shotCount).toStringAsFixed(1)
|
||||||
: '-',
|
: '-',
|
||||||
|
subtitle: '/ $maxScore',
|
||||||
),
|
),
|
||||||
if (scoreResult != null)
|
if (scoreResult != null)
|
||||||
_buildScoreStat(
|
_buildScoreStat(
|
||||||
context,
|
context,
|
||||||
'Pourcentage',
|
'Réussite',
|
||||||
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
'${scoreResult!.percentage.toStringAsFixed(0)}%',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -95,6 +142,30 @@ class ScoreCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bandeau compact rappelant le score total de la session en cours.
|
||||||
|
Widget _buildSessionBadge(BuildContext context) {
|
||||||
|
// Le nombre de cibles n'est rappelé qu'à partir de la deuxième : sur la
|
||||||
|
// première il n'apporte rien et allonge le bandeau pour rien.
|
||||||
|
final cibles = sessionTargetCount > 1 ? ' ($sessionTargetCount cibles)' : '';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Session : $sessionTotalScore pts$cibles',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildScoreStat(
|
Widget _buildScoreStat(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String label,
|
String label,
|
||||||
|
|||||||
@@ -4,25 +4,34 @@
|
|||||||
/// Les anneaux sont répartis proportionnellement.
|
/// Les anneaux sont répartis proportionnellement.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:math' as math;
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
|
||||||
import '../../../data/models/target_type.dart';
|
import '../../../data/models/target_type.dart';
|
||||||
|
|
||||||
class TargetCalibration extends StatefulWidget {
|
class TargetCalibration extends StatefulWidget {
|
||||||
final double initialCenterX;
|
final double initialCenterX;
|
||||||
final double initialCenterY;
|
final double initialCenterY;
|
||||||
final double initialRadius;
|
final double initialRadius;
|
||||||
|
final double initialInnerRadius;
|
||||||
final int initialRingCount;
|
final int initialRingCount;
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
final List<double>? initialRingRadii;
|
final List<double>? initialRingRadii;
|
||||||
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? ringRadii}) onCalibrationChanged;
|
final Function(
|
||||||
|
double centerX,
|
||||||
|
double centerY,
|
||||||
|
double innerRadius,
|
||||||
|
double radius,
|
||||||
|
int ringCount, {
|
||||||
|
List<double>? ringRadii,
|
||||||
|
})
|
||||||
|
onCalibrationChanged;
|
||||||
|
|
||||||
const TargetCalibration({
|
const TargetCalibration({
|
||||||
super.key,
|
super.key,
|
||||||
required this.initialCenterX,
|
required this.initialCenterX,
|
||||||
required this.initialCenterY,
|
required this.initialCenterY,
|
||||||
required this.initialRadius,
|
required this.initialRadius,
|
||||||
|
required this.initialInnerRadius,
|
||||||
this.initialRingCount = 10,
|
this.initialRingCount = 10,
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
this.initialRingRadii,
|
this.initialRingRadii,
|
||||||
@@ -30,18 +39,38 @@ class TargetCalibration extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TargetCalibration> createState() => _TargetCalibrationState();
|
State<TargetCalibration> createState() => TargetCalibrationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TargetCalibrationState extends State<TargetCalibration> {
|
class TargetCalibrationState extends State<TargetCalibration> {
|
||||||
|
/// Bornes du rayon global (mesurées pour laisser de la liberté sans
|
||||||
|
/// débordement incontrôlé).
|
||||||
|
static const double minRadius = 0.3;
|
||||||
|
static const double maxRadius = 0.95;
|
||||||
|
|
||||||
|
/// Bornes de l'espacement : max bridé à 0.70 pour confiner le dernier cercle.
|
||||||
|
static const double minSpacing = 0.01;
|
||||||
|
static const double maxSpacing = 0.70;
|
||||||
|
|
||||||
late double _centerX;
|
late double _centerX;
|
||||||
late double _centerY;
|
late double _centerY;
|
||||||
late double _radius;
|
late double _radius;
|
||||||
|
late double _innerRadius;
|
||||||
late int _ringCount;
|
late int _ringCount;
|
||||||
late List<double> _ringRadii;
|
late List<double> _ringRadii;
|
||||||
|
|
||||||
|
// Variable dédiée pour piloter la jauge orange sans conflit
|
||||||
|
late double _currentEspacementRatio;
|
||||||
|
|
||||||
bool _isDraggingCenter = false;
|
bool _isDraggingCenter = false;
|
||||||
bool _isDraggingRadius = false;
|
bool _isDraggingRadius = false;
|
||||||
|
bool _isDraggingInnerRadius = false;
|
||||||
|
|
||||||
|
bool _showEspacement = false;
|
||||||
|
double _baseRadiusBeforeScale = 1.0;
|
||||||
|
|
||||||
|
// SAUVEGARDE CRITIQUE : Pour mémoriser la structure d'origine de l'IA
|
||||||
|
List<double>? _originalRingRadii;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -49,28 +78,81 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
_centerX = widget.initialCenterX;
|
_centerX = widget.initialCenterX;
|
||||||
_centerY = widget.initialCenterY;
|
_centerY = widget.initialCenterY;
|
||||||
_radius = widget.initialRadius;
|
_radius = widget.initialRadius;
|
||||||
|
_innerRadius = widget.initialInnerRadius;
|
||||||
_ringCount = widget.initialRingCount;
|
_ringCount = widget.initialRingCount;
|
||||||
|
|
||||||
|
// On mémorise la configuration d'usine de l'image
|
||||||
|
if (widget.initialRingRadii != null) {
|
||||||
|
_originalRingRadii = List.from(widget.initialRingRadii!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialisation du ratio par défaut
|
||||||
|
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||||
_initRingRadii();
|
_initRingRadii();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initRingRadii() {
|
/// Fige et propage la calibration courante vers le provider.
|
||||||
if (widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
|
///
|
||||||
|
/// Appelée par l'écran d'analyse (via GlobalKey) juste avant de basculer
|
||||||
|
/// dans l'instance de Plotting, pour garantir que l'état affiché en Plotting
|
||||||
|
/// correspond exactement au dernier réglage validé, sans dépendre d'un
|
||||||
|
/// éventuel rebuild intermédiaire.
|
||||||
|
void commitCalibration() {
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initRingRadii({bool forceRecalculate = false}) {
|
||||||
|
// CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
|
||||||
|
if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
|
||||||
|
_ringRadii = List.from(_originalRingRadii!);
|
||||||
|
} else if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
|
||||||
_ringRadii = List.from(widget.initialRingRadii!);
|
_ringRadii = List.from(widget.initialRingRadii!);
|
||||||
} else {
|
} else {
|
||||||
// Initialize with default proportional radii
|
_ringRadii = List.generate(_ringCount, (i) {
|
||||||
_ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount);
|
if (_ringCount <= 1) return 1.0;
|
||||||
|
return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(TargetCalibration oldWidget) {
|
void didUpdateWidget(TargetCalibration oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
bool shouldReinit = false;
|
||||||
|
|
||||||
|
if (widget.initialCenterX != oldWidget.initialCenterX && !_isDraggingCenter) {
|
||||||
|
_centerX = widget.initialCenterX;
|
||||||
|
}
|
||||||
|
if (widget.initialCenterY != oldWidget.initialCenterY && !_isDraggingCenter) {
|
||||||
|
_centerY = widget.initialCenterY;
|
||||||
|
}
|
||||||
if (widget.initialRingCount != oldWidget.initialRingCount) {
|
if (widget.initialRingCount != oldWidget.initialRingCount) {
|
||||||
_ringCount = widget.initialRingCount;
|
_ringCount = widget.initialRingCount;
|
||||||
_initRingRadii();
|
shouldReinit = true;
|
||||||
}
|
}
|
||||||
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) {
|
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) {
|
||||||
_radius = widget.initialRadius;
|
_radius = widget.initialRadius;
|
||||||
|
shouldReinit = true;
|
||||||
|
}
|
||||||
|
if (widget.initialInnerRadius != oldWidget.initialInnerRadius && !_isDraggingInnerRadius) {
|
||||||
|
_innerRadius = widget.initialInnerRadius;
|
||||||
|
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||||
|
shouldReinit = true;
|
||||||
|
}
|
||||||
|
// On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
|
||||||
|
// réellement d'une nouvelle détection (ils diffèrent de notre état courant).
|
||||||
|
// Sinon il s'agit de l'écho de notre propre _notifyChange (aller-retour via
|
||||||
|
// le provider) : le clobber effacerait le profil d'origine et casserait le
|
||||||
|
// bouton de réinitialisation de l'espacement.
|
||||||
|
if (widget.initialRingRadii != oldWidget.initialRingRadii &&
|
||||||
|
widget.initialRingRadii != null &&
|
||||||
|
!listEquals(widget.initialRingRadii, _ringRadii)) {
|
||||||
|
_originalRingRadii = List.from(widget.initialRingRadii!);
|
||||||
|
shouldReinit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReinit) {
|
||||||
|
_initRingRadii();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,21 +162,36 @@ 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
|
||||||
|
// AU-DESSUS de l'image : rien ne vient masquer la cible ici.
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onPanStart: (details) => _onPanStart(details, size),
|
onScaleStart: (details) {
|
||||||
onPanUpdate: (details) => _onPanUpdate(details, size),
|
_baseRadiusBeforeScale = _radius;
|
||||||
onPanEnd: (_) => _onPanEnd(),
|
final tapX = details.localFocalPoint.dx / size.width;
|
||||||
|
final tapY = details.localFocalPoint.dy / size.height;
|
||||||
|
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
||||||
|
|
||||||
|
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
||||||
|
setState(() {
|
||||||
|
_isDraggingCenter = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScaleUpdate: (details) => _onScaleUpdate(details, size),
|
||||||
|
onScaleEnd: (_) => _onScaleEnd(),
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
size: size,
|
size: size,
|
||||||
painter: _CalibrationPainter(
|
painter: _CalibrationPainter(
|
||||||
centerX: _centerX,
|
centerX: _centerX,
|
||||||
centerY: _centerY,
|
centerY: _centerY,
|
||||||
radius: _radius,
|
radius: _radius,
|
||||||
|
innerRadius: _innerRadius,
|
||||||
ringCount: _ringCount,
|
ringCount: _ringCount,
|
||||||
ringRadii: _ringRadii,
|
ringRadii: _ringRadii,
|
||||||
targetType: widget.targetType,
|
targetType: widget.targetType,
|
||||||
isDraggingCenter: _isDraggingCenter,
|
isDraggingCenter: _isDraggingCenter,
|
||||||
isDraggingRadius: _isDraggingRadius,
|
isDraggingRadius: false,
|
||||||
|
isDraggingInnerRadius: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -102,60 +199,186 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanStart(DragStartDetails details, Size size) {
|
// ---------------------------------------------------------------------------
|
||||||
final tapX = details.localPosition.dx / size.width;
|
// API publique pilotée par l'écran hôte (panneau de réglages hors de l'image)
|
||||||
final tapY = details.localPosition.dy / size.height;
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Check if tapping on center handle
|
/// Espacement courant (rayon du premier anneau, en fraction du rayon global).
|
||||||
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
double get spacingRatio => _currentEspacementRatio;
|
||||||
|
|
||||||
// Check if tapping on radius handle (on the right edge of the outermost circle)
|
/// Mode d'espacement manuel actif ou non.
|
||||||
final minDim = math.min(size.width, size.height);
|
bool get isSpacingModeEnabled => _showEspacement;
|
||||||
final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0);
|
|
||||||
final radiusHandleX = _centerX + outerRadius * minDim / size.width;
|
|
||||||
final radiusHandleY = _centerY;
|
|
||||||
final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0));
|
|
||||||
|
|
||||||
if (distToCenter < 0.05) {
|
/// Applique une nouvelle taille globale (rayon normalisé).
|
||||||
|
void setRadius(double value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingCenter = true;
|
_radius = value.clamp(minRadius, maxRadius);
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
// Si le mode avancé n'est pas coché, on applique la taille pure sans
|
||||||
|
// détruire le ratio d'origine.
|
||||||
|
_initRingRadii(forceRecalculate: _showEspacement);
|
||||||
});
|
});
|
||||||
} else if (distToRadiusHandle < 0.05) {
|
_notifyChange();
|
||||||
setState(() {
|
|
||||||
_isDraggingRadius = true;
|
|
||||||
});
|
|
||||||
} else if (distToCenter < _radius + 0.02) {
|
|
||||||
// Tapping inside the target - move center
|
|
||||||
setState(() {
|
|
||||||
_isDraggingCenter = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanUpdate(DragUpdateDetails details, Size size) {
|
/// Agrandit / réduit la cible de [deltaPixels] pixels.
|
||||||
final deltaX = details.delta.dx / size.width;
|
///
|
||||||
final deltaY = details.delta.dy / size.height;
|
/// La conversion utilise la plus petite dimension de [size], exactement comme
|
||||||
final minDim = math.min(size.width, size.height);
|
/// 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(() {
|
setState(() {
|
||||||
if (_isDraggingCenter) {
|
_showEspacement = enabled;
|
||||||
// Move center
|
if (!enabled) {
|
||||||
|
_initRingRadii(forceRecalculate: false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applique un nouvel espacement entre les anneaux.
|
||||||
|
void setSpacingRatio(double value) {
|
||||||
|
setState(() {
|
||||||
|
_currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: true);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restaure le vrai profil d'usine détecté sur l'image.
|
||||||
|
void resetSpacing() {
|
||||||
|
setState(() {
|
||||||
|
if (_originalRingRadii != null) {
|
||||||
|
_initRingRadii(forceRecalculate: false);
|
||||||
|
if (_ringRadii.isNotEmpty) {
|
||||||
|
_currentEspacementRatio = _ringRadii.first;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_currentEspacementRatio = 0.1;
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildDirectionalControls(BuildContext context, Size size) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Réduit la cible d'un pixel.
|
||||||
|
_buildSizeButton('−', () => adjustRadiusByPixels(-1, size)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
|
||||||
|
const SizedBox(width: 40),
|
||||||
|
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Agrandit la cible d'un pixel.
|
||||||
|
_buildSizeButton('+', () => adjustRadiusByPixels(1, size)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bouton − / + de part et d'autre de la croix : ajuste la TAILLE de la cible.
|
||||||
|
Widget _buildSizeButton(String sign, VoidCallback onPressed) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Text(
|
||||||
|
sign,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
|
tooltip: sign == '+' ? 'Agrandir la cible' : 'Réduire la cible',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||||
|
margin: const EdgeInsets.all(2),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(icon, color: Colors.white, size: 28),
|
||||||
|
onPressed: onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifyChange() {
|
||||||
|
widget.onCalibrationChanged(
|
||||||
|
_centerX,
|
||||||
|
_centerY,
|
||||||
|
_innerRadius,
|
||||||
|
_radius,
|
||||||
|
_ringCount,
|
||||||
|
ringRadii: _ringRadii,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _moveCenterByPixels(double dx, double dy, Size size) {
|
||||||
|
setState(() {
|
||||||
|
_centerX = _centerX + (dx / size.width);
|
||||||
|
_centerY = _centerY + (dy / size.height);
|
||||||
|
});
|
||||||
|
_notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
|
||||||
|
setState(() {
|
||||||
|
if (details.pointerCount == 2) {
|
||||||
|
_radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius);
|
||||||
|
_innerRadius = _radius * _currentEspacementRatio;
|
||||||
|
_initRingRadii(forceRecalculate: _showEspacement);
|
||||||
|
} else if (_isDraggingCenter) {
|
||||||
|
final deltaX = details.focalPointDelta.dx / size.width;
|
||||||
|
final deltaY = details.focalPointDelta.dy / size.height;
|
||||||
_centerX = _centerX + deltaX;
|
_centerX = _centerX + deltaX;
|
||||||
_centerY = _centerY + deltaY;
|
_centerY = _centerY + deltaY;
|
||||||
} else if (_isDraggingRadius) {
|
|
||||||
// Adjust outer radius (scales all rings proportionally)
|
|
||||||
final newRadius = _radius + deltaX * (size.width / minDim);
|
|
||||||
_radius = newRadius.clamp(0.05, 3.0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
_notifyChange();
|
||||||
widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanEnd() {
|
void _onScaleEnd() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingCenter = false;
|
_isDraggingCenter = false;
|
||||||
_isDraggingRadius = false;
|
_isDraggingRadius = false;
|
||||||
|
_isDraggingInnerRadius = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,21 +393,25 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
final double centerX;
|
final double centerX;
|
||||||
final double centerY;
|
final double centerY;
|
||||||
final double radius;
|
final double radius;
|
||||||
|
final double innerRadius;
|
||||||
final int ringCount;
|
final int ringCount;
|
||||||
final List<double> ringRadii;
|
final List<double> ringRadii;
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
final bool isDraggingCenter;
|
final bool isDraggingCenter;
|
||||||
final bool isDraggingRadius;
|
final bool isDraggingRadius;
|
||||||
|
final bool isDraggingInnerRadius;
|
||||||
|
|
||||||
_CalibrationPainter({
|
_CalibrationPainter({
|
||||||
required this.centerX,
|
required this.centerX,
|
||||||
required this.centerY,
|
required this.centerY,
|
||||||
required this.radius,
|
required this.radius,
|
||||||
|
required this.innerRadius,
|
||||||
required this.ringCount,
|
required this.ringCount,
|
||||||
required this.ringRadii,
|
required this.ringRadii,
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
required this.isDraggingCenter,
|
required this.isDraggingCenter,
|
||||||
required this.isDraggingRadius,
|
required this.isDraggingRadius,
|
||||||
|
required this.isDraggingInnerRadius,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -199,18 +426,17 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
|
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw center handle
|
if (isDraggingCenter) {
|
||||||
|
final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
|
||||||
|
canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
|
||||||
|
canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
|
||||||
|
}
|
||||||
|
|
||||||
_drawCenterHandle(canvas, centerPx);
|
_drawCenterHandle(canvas, centerPx);
|
||||||
|
|
||||||
// Draw radius handle (for outer ring)
|
|
||||||
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
|
|
||||||
|
|
||||||
// Draw instructions
|
|
||||||
_drawInstructions(canvas, size);
|
_drawInstructions(canvas, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
|
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
|
||||||
// Generate colors for zones
|
|
||||||
List<Color> zoneColors = [];
|
List<Color> zoneColors = [];
|
||||||
for (int i = 0; i < ringCount; i++) {
|
for (int i = 0; i < ringCount; i++) {
|
||||||
final ratio = i / ringCount;
|
final ratio = i / ringCount;
|
||||||
@@ -231,9 +457,8 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
final strokePaint = Paint()
|
final strokePaint = Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 1
|
..strokeWidth = 1
|
||||||
..color = Colors.white.withValues(alpha: 0.6);
|
..color = Colors.red.withValues(alpha: 0.8);
|
||||||
|
|
||||||
// Draw from outside to inside
|
|
||||||
for (int i = ringCount - 1; i >= 0; i--) {
|
for (int i = ringCount - 1; i >= 0; i--) {
|
||||||
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
|
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
|
||||||
final zoneRadius = baseRadius * ringRadius;
|
final zoneRadius = baseRadius * ringRadius;
|
||||||
@@ -243,22 +468,14 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
canvas.drawCircle(center, zoneRadius, strokePaint);
|
canvas.drawCircle(center, zoneRadius, strokePaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw zone labels (only if within visible area)
|
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||||
final textPainter = TextPainter(
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (int i = 0; i < ringCount; i++) {
|
for (int i = 0; i < ringCount; i++) {
|
||||||
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
|
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
|
||||||
final prevRingRadius = i > 0
|
final textSpanRatio = i > 0 ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) : 0.0;
|
||||||
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
|
final zoneRadius = baseRadius * (ringRadius + textSpanRatio) / 2;
|
||||||
: 0.0;
|
|
||||||
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
|
|
||||||
|
|
||||||
// Score: center = 10, decrement by 1 for each ring
|
|
||||||
final score = 10 - i;
|
final score = 10 - i;
|
||||||
|
|
||||||
// Only draw label if it's within the visible area
|
|
||||||
final labelX = center.dx + zoneRadius;
|
final labelX = center.dx + zoneRadius;
|
||||||
if (labelX < 0 || labelX > size.width) continue;
|
if (labelX < 0 || labelX > size.width) continue;
|
||||||
|
|
||||||
@@ -268,14 +485,11 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
color: Colors.white.withValues(alpha: 0.9),
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
shadows: const [
|
shadows: const [Shadow(color: Colors.black, blurRadius: 2)],
|
||||||
Shadow(color: Colors.black, blurRadius: 2),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
textPainter.layout();
|
textPainter.layout();
|
||||||
|
|
||||||
// Draw label on the right side of each zone
|
|
||||||
final labelY = center.dy - textPainter.height / 2;
|
final labelY = center.dy - textPainter.height / 2;
|
||||||
if (labelY >= 0 && labelY <= size.height) {
|
if (labelY >= 0 && labelY <= size.height) {
|
||||||
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
|
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
|
||||||
@@ -284,143 +498,49 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
|
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
|
||||||
// Simplified silhouette zones
|
|
||||||
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
|
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
|
||||||
|
|
||||||
// Draw silhouette outline (simplified as rectangle for now)
|
|
||||||
final silhouetteWidth = radius * 0.8;
|
final silhouetteWidth = radius * 0.8;
|
||||||
final silhouetteHeight = radius * 2;
|
final silhouetteHeight = radius * 2;
|
||||||
|
|
||||||
paint.color = Colors.green.withValues(alpha: 0.5);
|
paint.color = Colors.green.withValues(alpha: 0.5);
|
||||||
canvas.drawRect(
|
canvas.drawRect(Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), paint);
|
||||||
Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight),
|
|
||||||
paint,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCenterHandle(Canvas canvas, Offset center) {
|
void _drawCenterHandle(Canvas canvas, Offset center) {
|
||||||
// Outer circle
|
final outerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.stroke..strokeWidth = 3;
|
||||||
final outerPaint = Paint()
|
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 3;
|
|
||||||
canvas.drawCircle(center, 15, outerPaint);
|
canvas.drawCircle(center, 15, outerPaint);
|
||||||
|
|
||||||
// Inner dot
|
final innerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.fill;
|
||||||
final innerPaint = Paint()
|
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
canvas.drawCircle(center, 5, innerPaint);
|
canvas.drawCircle(center, 5, innerPaint);
|
||||||
|
|
||||||
// Crosshair
|
final crossPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..strokeWidth = 2;
|
||||||
final crossPaint = Paint()
|
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
|
||||||
..strokeWidth = 2;
|
|
||||||
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
|
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
|
||||||
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
|
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
|
||||||
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
|
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
|
||||||
canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint);
|
canvas.drawLine(Offset(center.dx, center.dy + 4), Offset(center.dx, center.dy + 20), crossPaint);
|
||||||
}
|
|
||||||
|
|
||||||
void _drawRadiusHandle(Canvas canvas, Size size, Offset center, double baseRadius) {
|
|
||||||
// Radius handle on the right edge of the outermost ring
|
|
||||||
final outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0;
|
|
||||||
final actualRadius = baseRadius * outerRingRadius;
|
|
||||||
final actualHandleX = center.dx + actualRadius;
|
|
||||||
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
|
|
||||||
final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
|
|
||||||
final handlePos = Offset(clampedHandleX, clampedHandleY);
|
|
||||||
|
|
||||||
// Check if handle is clamped (radius extends beyond visible area)
|
|
||||||
final isClamped = actualHandleX > size.width - 20;
|
|
||||||
|
|
||||||
final paint = Paint()
|
|
||||||
..color = isDraggingRadius
|
|
||||||
? AppTheme.successColor
|
|
||||||
: (isClamped ? Colors.orange : AppTheme.warningColor)
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
|
|
||||||
// Draw handle as a small circle with arrows
|
|
||||||
canvas.drawCircle(handlePos, 14, paint);
|
|
||||||
|
|
||||||
// Draw arrow indicators
|
|
||||||
final arrowPaint = Paint()
|
|
||||||
..color = Colors.white
|
|
||||||
..strokeWidth = 2
|
|
||||||
..style = PaintingStyle.stroke;
|
|
||||||
|
|
||||||
// Left arrow
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx - 4, handlePos.dy),
|
|
||||||
Offset(handlePos.dx - 8, handlePos.dy - 4),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx - 4, handlePos.dy),
|
|
||||||
Offset(handlePos.dx - 8, handlePos.dy + 4),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Right arrow
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx + 4, handlePos.dy),
|
|
||||||
Offset(handlePos.dx + 8, handlePos.dy - 4),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(handlePos.dx + 4, handlePos.dy),
|
|
||||||
Offset(handlePos.dx + 8, handlePos.dy + 4),
|
|
||||||
arrowPaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Label
|
|
||||||
final textPainter = TextPainter(
|
|
||||||
text: const TextSpan(
|
|
||||||
text: 'RAYON',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(
|
|
||||||
canvas,
|
|
||||||
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawInstructions(Canvas canvas, Size size) {
|
void _drawInstructions(Canvas canvas, Size size) {
|
||||||
const instruction = 'Deplacez le centre ou ajustez le rayon';
|
const instruction = 'Deplacez le centre ou ajustez le rayon';
|
||||||
|
|
||||||
final textPainter = TextPainter(
|
final textPainter = TextPainter(
|
||||||
text: TextSpan(
|
text: TextSpan(text: instruction, style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 12, backgroundColor: Colors.black54)),
|
||||||
text: instruction,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
fontSize: 12,
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
textDirection: TextDirection.ltr,
|
||||||
);
|
);
|
||||||
textPainter.layout();
|
textPainter.layout();
|
||||||
textPainter.paint(
|
textPainter.paint(canvas, Offset((size.width - textPainter.width) / 2, size.height - 30));
|
||||||
canvas,
|
|
||||||
Offset((size.width - textPainter.width) / 2, size.height - 30),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) {
|
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||||
|
if (oldDelegate is! _CalibrationPainter) return true;
|
||||||
return centerX != oldDelegate.centerX ||
|
return centerX != oldDelegate.centerX ||
|
||||||
centerY != oldDelegate.centerY ||
|
centerY != oldDelegate.centerY ||
|
||||||
radius != oldDelegate.radius ||
|
radius != oldDelegate.radius ||
|
||||||
|
innerRadius != oldDelegate.innerRadius ||
|
||||||
ringCount != oldDelegate.ringCount ||
|
ringCount != oldDelegate.ringCount ||
|
||||||
isDraggingCenter != oldDelegate.isDraggingCenter ||
|
isDraggingCenter != oldDelegate.isDraggingCenter ||
|
||||||
isDraggingRadius != oldDelegate.isDraggingRadius ||
|
isDraggingRadius != oldDelegate.isDraggingRadius ||
|
||||||
|
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
|
||||||
ringRadii != oldDelegate.ringRadii;
|
ringRadii != oldDelegate.ringRadii;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
/// Overlay visuel de la cible.
|
/// Overlay visuel de la cible.
|
||||||
///
|
///
|
||||||
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement
|
/// Dessine les anneaux de la cible, les impacts et le cercle de groupement.
|
||||||
/// et les impacts de référence. Gère les interactions tactiles pour l'ajout
|
/// Gère uniquement la SÉLECTION d'impacts existants (tap sur un impact).
|
||||||
/// d'impacts et la sélection d'impacts existants.
|
/// L'AJOUT d'un impact est délégué à l'écran parent pour éviter tout conflit
|
||||||
|
/// de gestes avec le zoom/pan de l'InteractiveViewer.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -19,12 +20,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;
|
||||||
|
|
||||||
const TargetOverlay({
|
const TargetOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -36,28 +36,29 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
this.ringCount = 10,
|
this.ringCount = 10,
|
||||||
this.ringRadii,
|
this.ringRadii,
|
||||||
this.onShotTapped,
|
this.onShotTapped,
|
||||||
this.onAddShot,
|
|
||||||
this.groupingCenterX,
|
this.groupingCenterX,
|
||||||
this.groupingCenterY,
|
this.groupingCenterY,
|
||||||
this.groupingDiameter,
|
this.groupingDiameter,
|
||||||
this.referenceImpacts,
|
|
||||||
this.zoomScale = 1.0,
|
this.zoomScale = 1.0,
|
||||||
|
this.showRings = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
// IMPORTANT : plus de GestureDetector global ici.
|
||||||
builder: (context, constraints) {
|
// L'ancien GestureDetector (onTapUp couvrant toute la surface, en
|
||||||
return GestureDetector(
|
// HitTestBehavior.translucent) volait les pointeurs au pinch de
|
||||||
behavior: HitTestBehavior.translucent,
|
// l'InteractiveViewer parent et rendait le zoom capricieux.
|
||||||
onTapUp: (details) {
|
//
|
||||||
if (onAddShot != null) {
|
// Désormais :
|
||||||
// Utiliser les constraints pour un calcul précis
|
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
|
||||||
final relX = details.localPosition.dx / constraints.maxWidth;
|
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
|
||||||
final relY = details.localPosition.dy / constraints.maxHeight;
|
// zones de tap localisées (deferToChild) placées sur chaque impact —
|
||||||
onAddShot!(relX, relY);
|
// et UNIQUEMENT si [onShotTapped] est fourni. Sans callback, aucune zone
|
||||||
}
|
// de tap n'est créée : un tap pile sur un impact traverse jusqu'au parent
|
||||||
},
|
// au lieu d'être absorbé dans le vide.
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: false,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _TargetOverlayPainter(
|
painter: _TargetOverlayPainter(
|
||||||
shots: shots,
|
shots: shots,
|
||||||
@@ -70,51 +71,47 @@ class TargetOverlay extends StatelessWidget {
|
|||||||
groupingCenterX: groupingCenterX,
|
groupingCenterX: groupingCenterX,
|
||||||
groupingCenterY: groupingCenterY,
|
groupingCenterY: groupingCenterY,
|
||||||
groupingDiameter: groupingDiameter,
|
groupingDiameter: groupingDiameter,
|
||||||
referenceImpacts: referenceImpacts,
|
|
||||||
zoomScale: zoomScale,
|
zoomScale: zoomScale,
|
||||||
|
showRings: showRings,
|
||||||
),
|
),
|
||||||
child: Stack(
|
|
||||||
children: shots.map((shot) {
|
|
||||||
return Positioned(
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, innerConstraints) {
|
builder: (context, constraints) {
|
||||||
final x = shot.x * innerConstraints.maxWidth;
|
final onTapped = onShotTapped;
|
||||||
final y = shot.y * innerConstraints.maxHeight;
|
if (onTapped == null) return const SizedBox.expand();
|
||||||
// Zone de tap qui s'adapte au zoom (taille fixe à l'écran)
|
return Stack(
|
||||||
|
children: shots.map((shot) {
|
||||||
|
final x = shot.x * constraints.maxWidth;
|
||||||
|
final y = shot.y * constraints.maxHeight;
|
||||||
|
// Zone de tap qui reste constante à l'écran malgré le zoom.
|
||||||
final tapSize = 30 / zoomScale;
|
final tapSize = 30 / zoomScale;
|
||||||
final halfTapSize = tapSize / 2;
|
final halfTapSize = tapSize / 2;
|
||||||
return Stack(
|
return Positioned(
|
||||||
children: [
|
|
||||||
Positioned(
|
|
||||||
left: x - halfTapSize,
|
left: x - halfTapSize,
|
||||||
top: y - halfTapSize,
|
top: y - halfTapSize,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
// deferToChild : ne capte le toucher QUE sur la zone du
|
||||||
onTap: () => onShotTapped?.call(shot),
|
// Container (un cercle opaque au hit-test), pas ailleurs.
|
||||||
|
// Le reste de la surface reste donc disponible pour le
|
||||||
|
// pinch/pan de l'InteractiveViewer.
|
||||||
|
behavior: HitTestBehavior.deferToChild,
|
||||||
|
onTap: () => onTapped(shot),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: tapSize,
|
width: tapSize,
|
||||||
height: tapSize,
|
height: tapSize,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Colors.transparent,
|
// Opaque pour le hit-test (couleur transparente visuellement
|
||||||
|
// mais non nulle), pour que le tap soit bien capté ici.
|
||||||
|
color: Color(0x01000000),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,8 +127,8 @@ 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;
|
||||||
|
|
||||||
_TargetOverlayPainter({
|
_TargetOverlayPainter({
|
||||||
required this.shots,
|
required this.shots,
|
||||||
@@ -144,14 +141,16 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
// Draw target center indicator
|
// Draw target center indicator
|
||||||
|
if (showRings) {
|
||||||
_drawTargetCenter(canvas, size);
|
_drawTargetCenter(canvas, size);
|
||||||
|
}
|
||||||
|
|
||||||
// Draw grouping circle
|
// Draw grouping circle
|
||||||
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
|
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
|
||||||
@@ -162,13 +161,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
_drawImpact(canvas, size, shot);
|
_drawImpact(canvas, size, shot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw reference impacts (with different color)
|
|
||||||
if (referenceImpacts != null) {
|
|
||||||
for (final ref in referenceImpacts!) {
|
|
||||||
_drawReferenceImpact(canvas, size, ref);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawTargetCenter(Canvas canvas, Size size) {
|
void _drawTargetCenter(Canvas canvas, Size size) {
|
||||||
@@ -285,16 +277,17 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
final strokeWidth = 3 / zoomScale;
|
final strokeWidth = 3 / zoomScale;
|
||||||
final fontSize = 10 / zoomScale;
|
final fontSize = 10 / zoomScale;
|
||||||
|
|
||||||
// Draw outer circle (white outline for visibility)
|
// Draw outer circle (white outline for visibility) — gardé OPAQUE pour
|
||||||
|
// bien repérer le centre même quand le remplissage est transparent.
|
||||||
final outlinePaint = Paint()
|
final outlinePaint = Paint()
|
||||||
..color = AppTheme.impactOutlineColor
|
..color = AppTheme.impactOutlineColor
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = strokeWidth;
|
..strokeWidth = strokeWidth;
|
||||||
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
||||||
|
|
||||||
// Draw impact marker
|
// Draw impact marker — TRANSPARENCE 30% pour voir l'impact réel derrière
|
||||||
final impactPaint = Paint()
|
final impactPaint = Paint()
|
||||||
..color = AppTheme.impactColor
|
..color = AppTheme.impactColor.withValues(alpha: 0.3)
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
|
canvas.drawCircle(Offset(x, y), innerRadius, impactPaint);
|
||||||
|
|
||||||
@@ -317,48 +310,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) {
|
|
||||||
final x = ref.x * size.width;
|
|
||||||
final y = ref.y * size.height;
|
|
||||||
|
|
||||||
// Tailles fixes divisées par le zoom pour rester constantes à l'écran
|
|
||||||
final outerRadius = 12 / zoomScale;
|
|
||||||
final innerRadius = 10 / zoomScale;
|
|
||||||
final strokeWidth = 3 / zoomScale;
|
|
||||||
final fontSize = 12 / zoomScale;
|
|
||||||
|
|
||||||
// Draw outer circle (white outline for visibility)
|
|
||||||
final outlinePaint = Paint()
|
|
||||||
..color = Colors.white
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = strokeWidth;
|
|
||||||
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
|
||||||
|
|
||||||
// Draw reference marker (purple)
|
|
||||||
final refPaint = Paint()
|
|
||||||
..color = Colors.deepPurple
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
canvas.drawCircle(Offset(x, y), innerRadius, refPaint);
|
|
||||||
|
|
||||||
// Draw "R" to indicate reference
|
|
||||||
final textPainter = TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: 'R',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: fontSize,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(
|
|
||||||
canvas,
|
|
||||||
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
|
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
|
||||||
return shots != oldDelegate.shots ||
|
return shots != oldDelegate.shots ||
|
||||||
@@ -370,7 +321,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
/// Écran de recadrage d'image en format carré (1:1).
|
|
||||||
///
|
|
||||||
/// Permet à l'utilisateur de déplacer et zoomer l'image pour sélectionner
|
|
||||||
/// la zone à recadrer. Le carré de recadrage est fixe au centre de l'écran.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
import '../../services/image_crop_service.dart';
|
import '../../services/image_crop_service.dart';
|
||||||
@@ -17,11 +11,15 @@ import 'widgets/crop_overlay.dart';
|
|||||||
class CropScreen extends StatefulWidget {
|
class CropScreen extends StatefulWidget {
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
|
final double? initialScale;
|
||||||
|
final Offset? initialOffset;
|
||||||
|
|
||||||
const CropScreen({
|
const CropScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imagePath,
|
required this.imagePath,
|
||||||
required this.targetType,
|
required this.targetType,
|
||||||
|
this.initialScale,
|
||||||
|
this.initialOffset,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -29,120 +27,265 @@ 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
|
||||||
|
double _scale = 1.0;
|
||||||
|
double _baseScale = 1.0;
|
||||||
|
Offset _offset = Offset.zero;
|
||||||
|
Offset _startOffset = Offset.zero;
|
||||||
|
Offset _startFocalPoint = Offset.zero;
|
||||||
|
|
||||||
|
// PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés
|
||||||
|
double _rotation = 0.0;
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _imageLoaded = false;
|
bool _imageLoaded = false;
|
||||||
Size? _imageSize;
|
Size? _imageSize;
|
||||||
|
late Size _viewportSize;
|
||||||
// Position et échelle de l'image
|
late double _cropSize;
|
||||||
Offset _offset = Offset.zero;
|
|
||||||
double _scale = 1.0;
|
|
||||||
double _baseScale = 1.0;
|
|
||||||
Offset _startFocalPoint = Offset.zero;
|
|
||||||
Offset _startOffset = Offset.zero;
|
|
||||||
|
|
||||||
// Dimensions calculées
|
|
||||||
double _cropSize = 0;
|
|
||||||
Size _viewportSize = Size.zero;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadImageDimensions();
|
_loadImageInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadImageDimensions() async {
|
Future<void> _loadImageInfo() async {
|
||||||
final file = File(widget.imagePath);
|
final file = File(widget.imagePath);
|
||||||
final bytes = await file.readAsBytes();
|
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||||||
final codec = await ui.instantiateImageCodec(bytes);
|
if (mounted) {
|
||||||
final frame = await codec.getNextFrame();
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_imageSize = Size(
|
_imageSize = Size(
|
||||||
frame.image.width.toDouble(),
|
decodedImage.width.toDouble(),
|
||||||
frame.image.height.toDouble(),
|
decodedImage.height.toDouble(),
|
||||||
);
|
);
|
||||||
_imageLoaded = true;
|
_imageLoaded = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: const Color(0xFF101214),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: const Color(0xFF101214),
|
||||||
foregroundColor: Colors.white,
|
elevation: 0,
|
||||||
title: const Text('Recadrer'),
|
centerTitle: true,
|
||||||
|
title: const Text(
|
||||||
|
'Centrage de la cible',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||||
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_isLoading)
|
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.check),
|
icon: const Icon(Icons.refresh, color: Colors.white70),
|
||||||
onPressed: _onCropConfirm,
|
tooltip: 'Réinitialiser l\'orientation',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_rotation = 0.0;
|
||||||
|
_scale = 1.0;
|
||||||
|
_offset = Offset.zero;
|
||||||
|
_initializeImagePosition();
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _isLoading
|
||||||
);
|
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||||
}
|
: Column(
|
||||||
|
children: [
|
||||||
Widget _buildBody() {
|
// TEXTE D'AIDE — placé en haut, sous le titre, au-dessus de l'image.
|
||||||
if (!_imageLoaded || _imageSize == null) {
|
Padding(
|
||||||
return const Center(
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isLoading) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(color: Colors.white),
|
const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Zoomer au maximum puis aligner votre cible',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Recadrage en cours...',
|
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.8)),
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Zone interactive de crop, CARRÉE. La photo la remplit entièrement
|
||||||
|
// (BoxFit.cover) → aucun bord noir dans la zone. Le débord hors cadre
|
||||||
|
// est récupérable en déplaçant/zoomant. La sortie d'analyse reste
|
||||||
|
// carrée, donc la cible n'est pas déformée.
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Container(
|
||||||
|
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),
|
||||||
|
|
||||||
|
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Rotation de l\'image',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${_rotation.toStringAsFixed(1)}°',
|
||||||
|
style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Les icônes de sens de rotation ont rejoint les boutons − / +
|
||||||
|
// de la croix directionnelle.
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: _rotation,
|
||||||
|
min: _minRotation,
|
||||||
|
max: _maxRotation,
|
||||||
|
divisions: 300,
|
||||||
|
label: '${_rotation.toStringAsFixed(1)}°',
|
||||||
|
activeColor: const Color(0xFF1A73E8),
|
||||||
|
inactiveColor: Colors.white12,
|
||||||
|
onChanged: (double value) {
|
||||||
|
setState(() {
|
||||||
|
_rotation = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_rotation = 0.0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Boutons du bas
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.white24),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
child: const Text('Retour', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _onCropConfirm,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF1A73E8),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildInteractiveCrop() {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
|
||||||
// Taille du carré de crop (90% de la plus petite dimension)
|
// La photo remplit toute la zone carrée (BoxFit.cover). La fenêtre de
|
||||||
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.85;
|
// visée = toute la zone visible → aucun bord noir autour du cadre.
|
||||||
|
_cropSize = math.min(_viewportSize.width, _viewportSize.height);
|
||||||
|
|
||||||
// Calculer l'échelle initiale si pas encore fait
|
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
|
||||||
if (_scale == 1.0 && _offset == Offset.zero) {
|
|
||||||
_initializeImagePosition();
|
_initializeImagePosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onScaleStart: _onScaleStart,
|
onScaleStart: _onScaleStart,
|
||||||
onScaleUpdate: _onScaleUpdate,
|
onScaleUpdate: _onScaleUpdate,
|
||||||
onScaleEnd: _onScaleEnd,
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Image transformée
|
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Transform(
|
child: Transform(
|
||||||
transform: Matrix4.identity()
|
transform: Matrix4.identity()
|
||||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
||||||
..scale(_scale, _scale, 1.0),
|
..scaleByDouble(_scale, _scale, _scale, 1.0)
|
||||||
|
..rotateZ(_rotation * (math.pi / 180)),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Image.file(
|
child: Image.file(
|
||||||
File(widget.imagePath),
|
File(widget.imagePath),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.cover,
|
||||||
width: _viewportSize.width,
|
width: _viewportSize.width,
|
||||||
height: _viewportSize.height,
|
height: _viewportSize.height,
|
||||||
),
|
),
|
||||||
@@ -150,22 +293,58 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Overlay de recadrage
|
// 2. Les lignes vertes de visée prolongées
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: CropOverlay(
|
child: Stack(
|
||||||
cropSize: _cropSize,
|
children: [
|
||||||
showGrid: true,
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 1.5,
|
||||||
|
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 1.5,
|
||||||
|
height: double.infinity,
|
||||||
|
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Instructions en bas
|
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
|
||||||
Positioned(
|
Positioned.fill(
|
||||||
left: 0,
|
child: IgnorePointer(
|
||||||
right: 0,
|
child: ColorFiltered(
|
||||||
bottom: 24,
|
colorFilter: ColorFilter.mode(
|
||||||
child: _buildInstructions(),
|
const Color(0xFF101214),
|
||||||
|
BlendMode.srcOut,
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: _cropSize,
|
||||||
|
height: _cropSize,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 4. Ton overlay avec les coins blancs
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: CropOverlay(cropSize: _cropSize, showGrid: false),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -177,25 +356,124 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
void _initializeImagePosition() {
|
void _initializeImagePosition() {
|
||||||
if (_imageSize == null) return;
|
if (_imageSize == null) return;
|
||||||
|
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
_scale = widget.initialScale ?? 1.0;
|
||||||
|
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
|
||||||
|
|
||||||
// Calculer la taille de l'image affichée (avec BoxFit.contain)
|
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
|
||||||
double displayWidth, displayHeight;
|
if (widget.initialOffset != null) {
|
||||||
if (imageAspect > viewportAspect) {
|
_offset = widget.initialOffset!;
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
} else {
|
||||||
displayHeight = _viewportSize.height;
|
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Échelle pour que le plus petit côté de l'image remplisse le carré de crop
|
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
|
||||||
final minDisplayDim = math.min(displayWidth, displayHeight);
|
// Les boutons « − » et « + » de part et d'autre pivotent l'image de 0,1°.
|
||||||
_scale = _cropSize / minDisplayDim;
|
Widget _buildDirectionalPad() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildRotationButton(
|
||||||
|
icon: Icons.rotate_left,
|
||||||
|
sign: '−',
|
||||||
|
iconFirst: true,
|
||||||
|
tooltip: 'Pivoter vers la gauche',
|
||||||
|
onPressed: () => _rotateBy(-_rotationStep),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildCropDirButton(Icons.keyboard_arrow_up, () => _nudge(0, -1)),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildCropDirButton(Icons.keyboard_arrow_left, () => _nudge(-1, 0)),
|
||||||
|
const SizedBox(width: 28),
|
||||||
|
_buildCropDirButton(Icons.keyboard_arrow_right, () => _nudge(1, 0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildCropDirButton(Icons.keyboard_arrow_down, () => _nudge(0, 1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_buildRotationButton(
|
||||||
|
icon: Icons.rotate_right,
|
||||||
|
sign: '+',
|
||||||
|
iconFirst: false,
|
||||||
|
tooltip: 'Pivoter vers la droite',
|
||||||
|
onPressed: () => _rotateBy(_rotationStep),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// S'assurer d'un scale minimum
|
Widget _buildCropDirButton(IconData icon, VoidCallback onPressed) {
|
||||||
if (_scale < 1.0) _scale = 1.0;
|
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) {
|
||||||
@@ -206,138 +484,69 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
|
|
||||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
// Mise à jour du scale
|
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
|
||||||
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
|
|
||||||
|
|
||||||
// Mise à jour de la position
|
|
||||||
final delta = details.focalPoint - _startFocalPoint;
|
final delta = details.focalPoint - _startFocalPoint;
|
||||||
_offset = _startOffset + delta;
|
_offset = _startOffset + delta;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScaleEnd(ScaleEndDetails details) {
|
|
||||||
// Optionnel: contraindre l'image pour qu'elle couvre toujours le carré de crop
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInstructions() {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.touch_app,
|
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Déplacez et zoomez pour cadrer la cible',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onCropConfirm() async {
|
Future<void> _onCropConfirm() async {
|
||||||
if (_imageSize == null) return;
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Calculer la zone de crop en coordonnées normalisées de l'image
|
// Facteur d'échelle affichage/source (BoxFit.cover) — identique à celui
|
||||||
final cropRect = _calculateCropRect();
|
// utilisé pour afficher l'image dans l'aperçu : l'image remplit la zone,
|
||||||
|
// le débord est rogné, donc l'échelle est le MAX des deux ratios d'axe.
|
||||||
// Recadrer l'image
|
final double displayPerSourcePx = math.max(
|
||||||
final croppedPath = await _cropService.cropToSquare(
|
_viewportSize.width / _imageSize!.width,
|
||||||
widget.imagePath,
|
_viewportSize.height / _imageSize!.height,
|
||||||
cropRect,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
|
||||||
|
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
|
||||||
|
// sont remplis en noir → la position choisie est respectée à l'identique.
|
||||||
|
final croppedImagePath = await _cropService.cropViewport(
|
||||||
|
sourcePath: widget.imagePath,
|
||||||
|
offsetDx: _offset.dx,
|
||||||
|
offsetDy: _offset.dy,
|
||||||
|
displayPerSourcePx: displayPerSourcePx,
|
||||||
|
cropSizeDisplay: _cropSize,
|
||||||
|
// Le zoom sert UNIQUEMENT à viser le bon point (mapping du décalage) ;
|
||||||
|
// il n'agrandit pas le rendu de sortie.
|
||||||
|
zoomScale: _scale,
|
||||||
|
rotationDegrees: _rotation,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetCenterX = 0.5;
|
||||||
|
const targetCenterY = 0.5;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Naviguer vers l'écran d'analyse avec l'image recadrée
|
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => AnalysisScreen(
|
builder: (_) => AnalysisScreen(
|
||||||
imagePath: croppedPath,
|
imagePath: croppedImagePath,
|
||||||
|
// AJOUT : on conserve la SOURCE non rognée pour les retours arrière.
|
||||||
|
// Sans cela, revenir au crop repartait de l'image déjà rognée à 85%,
|
||||||
|
// provoquant un zoom cumulatif (0.85 x 0.85 x ...) à chaque aller-retour.
|
||||||
|
originalImagePath: widget.imagePath,
|
||||||
targetType: widget.targetType,
|
targetType: widget.targetType,
|
||||||
|
initialCenterX: targetCenterX,
|
||||||
|
initialCenterY: targetCenterY,
|
||||||
|
cropScale: 1.0,
|
||||||
|
cropOffset: Offset.zero,
|
||||||
|
cropRotation: 0.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
|
||||||
content: Text('Erreur lors du recadrage: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CropRect _calculateCropRect() {
|
|
||||||
if (_imageSize == null) {
|
|
||||||
return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
|
||||||
|
|
||||||
// Calculer la taille de l'image affichée (avec BoxFit.contain)
|
|
||||||
double displayWidth, displayHeight;
|
|
||||||
if (imageAspect > viewportAspect) {
|
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
|
||||||
displayHeight = _viewportSize.height;
|
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Taille de l'image après scale
|
|
||||||
final scaledWidth = displayWidth * _scale;
|
|
||||||
final scaledHeight = displayHeight * _scale;
|
|
||||||
|
|
||||||
// Position du centre de l'image dans le viewport
|
|
||||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
|
||||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
|
||||||
|
|
||||||
// Position du coin supérieur gauche de l'image
|
|
||||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
|
||||||
final imageTop = imageCenterY - scaledHeight / 2;
|
|
||||||
|
|
||||||
// Position du carré de crop (centré dans le viewport)
|
|
||||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
|
||||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
|
||||||
|
|
||||||
// Convertir en coordonnées relatives à l'image affichée
|
|
||||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
|
||||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
|
||||||
final relCropSize = _cropSize / scaledWidth;
|
|
||||||
final relCropSizeY = _cropSize / scaledHeight;
|
|
||||||
|
|
||||||
return CropRect(
|
|
||||||
x: relCropLeft.clamp(0.0, 1.0),
|
|
||||||
y: relCropTop.clamp(0.0, 1.0),
|
|
||||||
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)),
|
|
||||||
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -13,20 +13,13 @@ class CropOverlay extends StatelessWidget {
|
|||||||
/// Afficher la grille des tiers
|
/// Afficher la grille des tiers
|
||||||
final bool showGrid;
|
final bool showGrid;
|
||||||
|
|
||||||
const CropOverlay({
|
const CropOverlay({super.key, required this.cropSize, this.showGrid = true});
|
||||||
super.key,
|
|
||||||
required this.cropSize,
|
|
||||||
this.showGrid = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
size: Size.infinite,
|
size: Size.infinite,
|
||||||
painter: _CropOverlayPainter(
|
painter: _CropOverlayPainter(cropSize: cropSize, showGrid: showGrid),
|
||||||
cropSize: cropSize,
|
|
||||||
showGrid: showGrid,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,10 +28,7 @@ class _CropOverlayPainter extends CustomPainter {
|
|||||||
final double cropSize;
|
final double cropSize;
|
||||||
final bool showGrid;
|
final bool showGrid;
|
||||||
|
|
||||||
_CropOverlayPainter({
|
_CropOverlayPainter({required this.cropSize, required this.showGrid});
|
||||||
required this.cropSize,
|
|
||||||
required this.showGrid,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
@@ -77,6 +67,9 @@ class _CropOverlayPainter extends CustomPainter {
|
|||||||
if (showGrid) {
|
if (showGrid) {
|
||||||
_drawGrid(canvas, cropRect);
|
_drawGrid(canvas, cropRect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dessiner le point central (croix interne vert fluo)
|
||||||
|
_drawCenterPoint(canvas, cropRect);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCorners(Canvas canvas, Rect rect) {
|
void _drawCorners(Canvas canvas, Rect rect) {
|
||||||
@@ -171,6 +164,43 @@ class _CropOverlayPainter extends CustomPainter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _drawCenterPoint(Canvas canvas, Rect rect) {
|
||||||
|
// Peinture de la croix interne (Vert Fluo)
|
||||||
|
final centerPaint = Paint()
|
||||||
|
..color = const Color(0xFF00FF00)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.0;
|
||||||
|
|
||||||
|
// Cercle extérieur (Vert Fluo fin)
|
||||||
|
final circlePaint = Paint()
|
||||||
|
..color = const Color(0xFF00FF00).withValues(alpha: 0.9)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2;
|
||||||
|
|
||||||
|
const circleRadius = 10.0; // Augmenté légèrement à 10 pour que la croix respire
|
||||||
|
final centerX = rect.center.dx;
|
||||||
|
final centerY = rect.center.dy;
|
||||||
|
|
||||||
|
// 1. Dessiner le cercle de contour
|
||||||
|
canvas.drawCircle(rect.center, circleRadius, circlePaint);
|
||||||
|
|
||||||
|
// 2. Dessiner la croix UNIQUEMENT à l'intérieur du cercle
|
||||||
|
|
||||||
|
// Ligne horizontale (de gauche à droite, reste à l'intérieur)
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(centerX - circleRadius, centerY),
|
||||||
|
Offset(centerX + circleRadius, centerY),
|
||||||
|
centerPaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ligne verticale (de haut en bas, reste à l'intérieur)
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(centerX, centerY - circleRadius),
|
||||||
|
Offset(centerX, centerY + circleRadius),
|
||||||
|
centerPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
|
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
|
||||||
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;
|
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;
|
||||||
|
|||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/weapon_type_icon.dart';
|
||||||
|
import '../../data/models/weapon.dart';
|
||||||
|
import '../../data/models/maintenance.dart';
|
||||||
|
import '../../data/repositories/session_repository.dart';
|
||||||
|
|
||||||
|
class WeaponDetailScreen extends StatefulWidget {
|
||||||
|
final Weapon weapon;
|
||||||
|
const WeaponDetailScreen({super.key, required this.weapon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeaponDetailScreen> createState() => _WeaponDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||||
|
late Weapon _weapon;
|
||||||
|
List<MaintenanceEntry> _maintenance = [];
|
||||||
|
int _totalRounds = 0;
|
||||||
|
int _sessionCount = 0;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_weapon = widget.weapon;
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final history = await repository.getMaintenanceHistory(_weapon.id);
|
||||||
|
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
|
||||||
|
final sessions = await repository.getSessionCountForWeapon(_weapon.id);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_maintenance = history;
|
||||||
|
_totalRounds = rounds;
|
||||||
|
_sessionCount = sessions;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_weapon.customName ?? _weapon.name),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
onPressed: _showEditWeaponDialog,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildStatsSection(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildInfoCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildOptionsCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildHistorySection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: _showAddMaintenanceDialog,
|
||||||
|
label: const Text('Entretien'),
|
||||||
|
icon: const Icon(Icons.build),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatsSection() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard(
|
||||||
|
'Total Tirs',
|
||||||
|
_totalRounds.toString(),
|
||||||
|
Icons.ads_click,
|
||||||
|
AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard(
|
||||||
|
'Sessions',
|
||||||
|
_sessionCount.toString(),
|
||||||
|
Icons.history,
|
||||||
|
Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const Divider(),
|
||||||
|
_buildInfoRow('Modèle', _weapon.name),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Type', style: TextStyle(color: Colors.grey)),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
WeaponTypeIcon(type: _weapon.type, size: 16),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_weapon.type.displayName,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildInfoRow('Calibre', _weapon.caliber),
|
||||||
|
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
|
||||||
|
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Notes:', style: TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
Text(_weapon.notes!, style: const TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOptionsCard() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Modifier les accessoires (crée une nouvelle configuration)',
|
||||||
|
icon: const Icon(Icons.edit, color: Colors.white, size: 18),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
minimumSize: const Size(36, 36),
|
||||||
|
),
|
||||||
|
onPressed: _showEditAccessoriesDialog,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
|
||||||
|
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
|
||||||
|
_buildInfoRow('Détente', _weapon.trigger ?? 'Standard'),
|
||||||
|
if (_weapon.customName != null)
|
||||||
|
_buildInfoRow('Surnom', _weapon.customName!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(color: Colors.grey)),
|
||||||
|
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistorySection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Historique d\'entretien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_maintenance.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||||
|
child: Center(child: Text('Aucun entretien enregistré', style: TextStyle(color: Colors.grey))),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: _maintenance.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = _maintenance[index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: _getMaintenanceColor(entry.type).withValues(alpha: 0.1),
|
||||||
|
child: Icon(_getMaintenanceIcon(entry.type), color: _getMaintenanceColor(entry.type), size: 20),
|
||||||
|
),
|
||||||
|
title: Text(entry.type.displayName),
|
||||||
|
subtitle: Text(entry.description),
|
||||||
|
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
|
||||||
|
onLongPress: () => _confirmDeleteMaintenance(entry),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getMaintenanceColor(MaintenanceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MaintenanceType.cleaning: return Colors.blue;
|
||||||
|
case MaintenanceType.repair: return Colors.red;
|
||||||
|
case MaintenanceType.inspection: return Colors.green;
|
||||||
|
case MaintenanceType.upgrade: return Colors.purple;
|
||||||
|
case MaintenanceType.other: return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getMaintenanceIcon(MaintenanceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case MaintenanceType.cleaning: return Icons.opacity;
|
||||||
|
case MaintenanceType.repair: return Icons.build;
|
||||||
|
case MaintenanceType.inspection: return Icons.visibility;
|
||||||
|
case MaintenanceType.upgrade: return Icons.trending_up;
|
||||||
|
case MaintenanceType.other: return Icons.more_horiz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recentre le champ qui prend le focus au milieu de la vue restante une fois
|
||||||
|
// le clavier ouvert. Sans cela, l'AlertDialog se rétrécit et le champ ciblé
|
||||||
|
// (ex: Modérateur) se retrouve caché sous le clavier.
|
||||||
|
Widget _autoScrollOnFocus(Widget child) {
|
||||||
|
return Builder(
|
||||||
|
builder: (context) => Focus(
|
||||||
|
canRequestFocus: false,
|
||||||
|
skipTraversal: true,
|
||||||
|
onFocusChange: (hasFocus) {
|
||||||
|
if (!hasFocus) return;
|
||||||
|
// On attend que le clavier ait fini de redimensionner la vue.
|
||||||
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
|
if (context.mounted) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
context,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditWeaponDialog() async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final nameController = TextEditingController(text: _weapon.name);
|
||||||
|
final caliberController = TextEditingController(text: _weapon.caliber);
|
||||||
|
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
|
||||||
|
final magCapController = TextEditingController(text: _weapon.magazineCapacity.toString());
|
||||||
|
final notesController = TextEditingController(text: _weapon.notes);
|
||||||
|
final opticController = TextEditingController(text: _weapon.optic);
|
||||||
|
final silencerController = TextEditingController(text: _weapon.silencer);
|
||||||
|
final triggerController = TextEditingController(text: _weapon.trigger);
|
||||||
|
final customNameController = TextEditingController(text: _weapon.customName);
|
||||||
|
WeaponType selectedType = _weapon.type;
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: const Text('Modifier l\'arme'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: customNameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
||||||
|
)),
|
||||||
|
DropdownButtonFormField<WeaponType>(
|
||||||
|
initialValue: selectedType,
|
||||||
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
|
items: WeaponType.values
|
||||||
|
.map(
|
||||||
|
(t) => DropdownMenuItem(
|
||||||
|
value: t,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
WeaponTypeIcon(type: t, size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(t.displayName),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
|
),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: caliberController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Calibre'),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: opticController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: silencerController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: triggerController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
_autoScrollOnFocus(Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: magCountController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Chargeurs'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: magCapController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Capacité'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: notesController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Notes'),
|
||||||
|
maxLines: 2,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Enregistrer')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
final updatedWeapon = _weapon.copyWith(
|
||||||
|
name: nameController.text,
|
||||||
|
type: selectedType,
|
||||||
|
caliber: caliberController.text,
|
||||||
|
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
||||||
|
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
||||||
|
notes: notesController.text,
|
||||||
|
optic: opticController.text.isEmpty ? null : opticController.text,
|
||||||
|
silencer: silencerController.text.isEmpty ? null : silencerController.text,
|
||||||
|
trigger: triggerController.text.isEmpty ? null : triggerController.text,
|
||||||
|
customName: customNameController.text.isEmpty ? null : customNameController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.updateWeapon(updatedWeapon);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_weapon = updatedWeapon;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Édition rapide des accessoires uniquement. Comme la qualité de tir dépend
|
||||||
|
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
|
||||||
|
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
|
||||||
|
// pouvoir comparer les scores selon la configuration utilisée.
|
||||||
|
void _showEditAccessoriesDialog() async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final opticController = TextEditingController(text: _weapon.optic);
|
||||||
|
final silencerController = TextEditingController(text: _weapon.silencer);
|
||||||
|
final triggerController = TextEditingController(text: _weapon.trigger);
|
||||||
|
final customNameController = TextEditingController(text: _weapon.customName);
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Modifier les accessoires'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Une nouvelle arme sera créée dans l\'armurerie avec ces accessoires, '
|
||||||
|
'pour pouvoir comparer les scores selon la configuration.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: opticController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: silencerController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: triggerController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
|
||||||
|
)),
|
||||||
|
_autoScrollOnFocus(TextField(
|
||||||
|
controller: customNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Surnom de la configuration',
|
||||||
|
hintText: 'ex: Echelon + Holosun',
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Créer la configuration')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != true || !mounted) return;
|
||||||
|
|
||||||
|
final newOptic = opticController.text.isEmpty ? null : opticController.text;
|
||||||
|
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
|
||||||
|
final newTrigger = triggerController.text.isEmpty ? null : triggerController.text;
|
||||||
|
var newCustomName = customNameController.text.isEmpty ? null : customNameController.text;
|
||||||
|
|
||||||
|
// Rien à faire si aucun accessoire n'a changé.
|
||||||
|
final unchanged = newOptic == _weapon.optic &&
|
||||||
|
newSilencer == _weapon.silencer &&
|
||||||
|
newTrigger == _weapon.trigger;
|
||||||
|
if (unchanged) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Génère un surnom par défaut décrivant la config si l'utilisateur n'en a pas saisi.
|
||||||
|
if (newCustomName == null) {
|
||||||
|
final accessories = [newOptic, newSilencer, newTrigger]
|
||||||
|
.where((a) => a != null && a.isNotEmpty)
|
||||||
|
.join(' / ');
|
||||||
|
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
|
||||||
|
}
|
||||||
|
|
||||||
|
final newWeapon = await repository.addWeapon(
|
||||||
|
name: _weapon.name,
|
||||||
|
type: _weapon.type,
|
||||||
|
caliber: _weapon.caliber,
|
||||||
|
magazineCount: _weapon.magazineCount,
|
||||||
|
magazineCapacity: _weapon.magazineCapacity,
|
||||||
|
notes: _weapon.notes,
|
||||||
|
optic: newOptic,
|
||||||
|
silencer: newSilencer,
|
||||||
|
trigger: newTrigger,
|
||||||
|
customName: newCustomName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Nouvelle configuration créée : ${newWeapon.displayName}')),
|
||||||
|
);
|
||||||
|
// On bascule sur la nouvelle arme pour que les sessions suivantes y soient rattachées.
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: newWeapon)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddMaintenanceDialog() async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final descController = TextEditingController();
|
||||||
|
MaintenanceType selectedType = MaintenanceType.cleaning;
|
||||||
|
DateTime selectedDate = DateTime.now();
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: const Text('Ajouter un entretien'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<MaintenanceType>(
|
||||||
|
initialValue: selectedType,
|
||||||
|
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
|
||||||
|
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||||
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: descController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedDate,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => selectedDate = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Date',
|
||||||
|
suffixIcon: Icon(Icons.calendar_today, size: 18),
|
||||||
|
),
|
||||||
|
child: Text(DateFormat('dd/MM/yyyy').format(selectedDate)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == true && descController.text.isNotEmpty) {
|
||||||
|
await repository.addMaintenanceEntry(
|
||||||
|
weaponId: _weapon.id,
|
||||||
|
type: selectedType,
|
||||||
|
description: descController.text,
|
||||||
|
roundsSinceLast: _totalRounds,
|
||||||
|
date: selectedDate,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
|
||||||
|
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Supprimer'),
|
||||||
|
content: const Text('Supprimer cette entrée de l\'historique ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await repository.deleteMaintenanceEntry(entry.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/glass_container.dart';
|
||||||
|
import '../../core/widgets/weapon_type_icon.dart';
|
||||||
|
import '../../data/models/weapon.dart';
|
||||||
|
import '../../data/repositories/session_repository.dart';
|
||||||
|
import 'weapon_detail_screen.dart';
|
||||||
|
|
||||||
|
class WeaponListScreen extends StatefulWidget {
|
||||||
|
final int refreshTick;
|
||||||
|
|
||||||
|
const WeaponListScreen({super.key, this.refreshTick = 0});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WeaponListScreen> createState() => _WeaponListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||||
|
List<Weapon> _weapons = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(WeaponListScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWeapons() async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final weapons = await repository.getWeapons();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_weapons = weapons;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
extendBody: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Mon Armurerie'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
tooltip: 'Ajouter une arme',
|
||||||
|
onPressed: _showAddWeaponDialog,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _weapons.isEmpty
|
||||||
|
? _buildEmptyState(isDark)
|
||||||
|
: _buildWeaponList(isDark),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState(bool isDark) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
GlassContainer(
|
||||||
|
borderRadius: 30,
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
child: Icon(
|
||||||
|
Icons.shield_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Aucune arme enregistrée',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Ajoutez vos armes pour suivre vos tirs, chargeurs et entretiens.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _showAddWeaponDialog,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Ajouter ma première arme'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWeaponList(bool isDark) {
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
AppConstants.defaultPadding,
|
||||||
|
12,
|
||||||
|
AppConstants.defaultPadding,
|
||||||
|
100, // Espace pour le floating dock
|
||||||
|
),
|
||||||
|
itemCount: _weapons.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final weapon = _weapons[index];
|
||||||
|
final accessories = _accessoryChips(weapon, isDark);
|
||||||
|
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
|
return GlassContainer(
|
||||||
|
borderRadius: 18,
|
||||||
|
blur: 14,
|
||||||
|
glowColor: primaryColor,
|
||||||
|
borderColor: isDark
|
||||||
|
? primaryColor.withValues(alpha: 0.18)
|
||||||
|
: primaryColor.withValues(alpha: 0.12),
|
||||||
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
onTap: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => WeaponDetailScreen(weapon: weapon),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadWeapons();
|
||||||
|
},
|
||||||
|
onLongPress: () => _confirmDelete(weapon),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
primaryColor.withValues(
|
||||||
|
alpha: isDark ? 0.25 : 0.15,
|
||||||
|
),
|
||||||
|
primaryColor.withValues(
|
||||||
|
alpha: 0.05,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: primaryColor.withValues(
|
||||||
|
alpha: isDark ? 0.35 : 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: WeaponTypeIcon(
|
||||||
|
type: weapon.type,
|
||||||
|
color: primaryColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
weapon.displayName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: isDark
|
||||||
|
? AppTheme.darkTextPrimary
|
||||||
|
: AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 7,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (isDark ? Colors.white : Colors.black)
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? AppTheme.darkBorder
|
||||||
|
: AppTheme.lightBorder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
weapon.caliber,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
weapon.type.displayName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isDark
|
||||||
|
? AppTheme.darkTextSecondary
|
||||||
|
: AppTheme.lightTextSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${weapon.magazineCount} chargeur${weapon.magazineCount > 1 ? 's' : ''}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark
|
||||||
|
? AppTheme.darkTextPrimary
|
||||||
|
: AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${weapon.magazineCapacity} coups/ch.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: isDark
|
||||||
|
? AppTheme.darkTextMuted
|
||||||
|
: AppTheme.lightTextMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (accessories.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: accessories,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _accessoryChips(Weapon weapon, bool isDark) {
|
||||||
|
final items = <(IconData, String)>[];
|
||||||
|
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
|
||||||
|
items.add((Icons.center_focus_strong, weapon.optic!));
|
||||||
|
}
|
||||||
|
if (weapon.silencer != null && weapon.silencer!.isNotEmpty) {
|
||||||
|
items.add((Icons.volume_off, weapon.silencer!));
|
||||||
|
}
|
||||||
|
if (weapon.trigger != null && weapon.trigger!.isNotEmpty) {
|
||||||
|
items.add((Icons.touch_app, weapon.trigger!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
item.$1,
|
||||||
|
size: 13,
|
||||||
|
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
item.$2,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddWeaponDialog() async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final nameController = TextEditingController();
|
||||||
|
final caliberController = TextEditingController();
|
||||||
|
final magCountController = TextEditingController(text: '2');
|
||||||
|
final magCapController = TextEditingController(text: '15');
|
||||||
|
WeaponType selectedType = WeaponType.handgun;
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogCtx) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: const Text('Ajouter une arme'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom de l\'arme',
|
||||||
|
hintText: 'ex: Glock 17 Gen 5',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<WeaponType>(
|
||||||
|
initialValue: selectedType,
|
||||||
|
decoration: const InputDecoration(labelText: 'Type'),
|
||||||
|
items: WeaponType.values
|
||||||
|
.map(
|
||||||
|
(t) => DropdownMenuItem(
|
||||||
|
value: t,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
WeaponTypeIcon(type: t, size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(t.displayName),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => selectedType = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: caliberController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Calibre',
|
||||||
|
hintText: 'ex: 9x19mm, .22 LR, .223 Rem',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: magCountController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Chargeurs'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: magCapController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Capacité'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||||
|
child: const Text('Ajouter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == true && nameController.text.isNotEmpty) {
|
||||||
|
await repository.addWeapon(
|
||||||
|
name: nameController.text,
|
||||||
|
type: selectedType,
|
||||||
|
caliber: caliberController.text,
|
||||||
|
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
||||||
|
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDelete(Weapon weapon) async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Supprimer l\'arme'),
|
||||||
|
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await repository.deleteWeapon(weapon.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
/// Écran d'historique des sessions.
|
|
||||||
///
|
|
||||||
/// Affiche la liste des sessions passées avec filtrage par type de cible.
|
|
||||||
/// Inclut un graphique d'évolution des scores (10 dernières sessions)
|
|
||||||
/// et permet la navigation vers le détail de chaque session.
|
|
||||||
library;
|
|
||||||
|
|
||||||
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';
|
||||||
@@ -18,7 +10,9 @@ import 'widgets/session_list_item.dart';
|
|||||||
import 'widgets/history_chart.dart';
|
import 'widgets/history_chart.dart';
|
||||||
|
|
||||||
class HistoryScreen extends StatefulWidget {
|
class HistoryScreen extends StatefulWidget {
|
||||||
const HistoryScreen({super.key});
|
final int refreshTick;
|
||||||
|
|
||||||
|
const HistoryScreen({super.key, this.refreshTick = 0});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||||
@@ -28,6 +22,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
List<Session> _sessions = [];
|
List<Session> _sessions = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
TargetType? _filterType;
|
TargetType? _filterType;
|
||||||
|
DateTimeRange? _selectedDateRange;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -35,18 +30,56 @@ 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;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final sessions = await repository.getAllSessions(
|
final sessions = await repository.getAllSessions();
|
||||||
targetType: _filterType,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sessions = sessions;
|
_sessions = sessions;
|
||||||
|
|
||||||
|
if (_filterType != null) {
|
||||||
|
_sessions = _sessions
|
||||||
|
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedDateRange != null) {
|
||||||
|
_sessions = _sessions.where((s) {
|
||||||
|
final sessionDate = DateTime(
|
||||||
|
s.createdAt.year,
|
||||||
|
s.createdAt.month,
|
||||||
|
s.createdAt.day,
|
||||||
|
);
|
||||||
|
final startDate = DateTime(
|
||||||
|
_selectedDateRange!.start.year,
|
||||||
|
_selectedDateRange!.start.month,
|
||||||
|
_selectedDateRange!.start.day,
|
||||||
|
);
|
||||||
|
final endDate = DateTime(
|
||||||
|
_selectedDateRange!.end.year,
|
||||||
|
_selectedDateRange!.end.month,
|
||||||
|
_selectedDateRange!.end.day,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sessionDate.isAtSameMomentAs(startDate) ||
|
||||||
|
sessionDate.isAtSameMomentAs(endDate) ||
|
||||||
|
(sessionDate.isAfter(startDate) &&
|
||||||
|
sessionDate.isBefore(endDate));
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,7 +88,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Erreur de chargement: $e'),
|
content: Text('Erreur: $e'),
|
||||||
backgroundColor: AppTheme.errorColor,
|
backgroundColor: AppTheme.errorColor,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -63,60 +96,204 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDateRange() async {
|
||||||
|
final picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
initialDateRange: _selectedDateRange,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _selectedDateRange = picked);
|
||||||
|
_loadSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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('Historique'),
|
title: const Text('Carnet de Tir'),
|
||||||
actions: [
|
actions: [
|
||||||
PopupMenuButton<TargetType?>(
|
IconButton(
|
||||||
icon: const Icon(Icons.filter_list),
|
icon: Icon(
|
||||||
tooltip: 'Filtrer',
|
Icons.date_range_outlined,
|
||||||
onSelected: (type) {
|
color: _selectedDateRange != null ? AppTheme.primaryColor : null,
|
||||||
setState(() => _filterType = type);
|
|
||||||
_loadSessions();
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: null,
|
|
||||||
child: Text('Tous'),
|
|
||||||
),
|
),
|
||||||
...TargetType.values.map((type) => PopupMenuItem(
|
tooltip: 'Filtrer par date',
|
||||||
value: type,
|
onPressed: _pickDateRange,
|
||||||
child: Text(type.displayName),
|
),
|
||||||
)),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
body: Column(
|
||||||
),
|
children: [
|
||||||
body: _isLoading
|
_buildFilterChips(isDark),
|
||||||
|
Expanded(
|
||||||
|
child: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _sessions.isEmpty
|
: _sessions.isEmpty
|
||||||
? _buildEmptyState()
|
? _buildEmptyState(isDark)
|
||||||
: _buildContent(),
|
: _buildContent(isDark),
|
||||||
|
),
|
||||||
|
if (_selectedDateRange != null) _buildActivePeriodBanner(isDark),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
Widget _buildFilterChips(bool isDark) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? AppTheme.darkBackground : AppTheme.lightBackground,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildTypeFilterChip('Toutes les cibles', null),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildTypeFilterChip(
|
||||||
|
'Cibles 1-10',
|
||||||
|
TargetType.concentric,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildTypeFilterChip(
|
||||||
|
'Silhouettes',
|
||||||
|
TargetType.silhouette,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTypeFilterChip(String label, TargetType? type) {
|
||||||
|
final isSelected = _filterType == type;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setState(() => _filterType = type);
|
||||||
|
_loadSessions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActivePeriodBanner(bool isDark) {
|
||||||
|
final start = DateFormat('dd/MM/yy').format(_selectedDateRange!.start);
|
||||||
|
final end = DateFormat('dd/MM/yy').format(_selectedDateRange!.end);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.event, size: 18, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Période : $start - $end',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState(bool isDark) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
Container(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.all(24),
|
||||||
Text(
|
decoration: BoxDecoration(
|
||||||
'Aucune session',
|
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurfaceVariant,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.history_toggle_off,
|
||||||
|
size: 56,
|
||||||
|
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
Text(
|
Text(
|
||||||
_filterType != null
|
'Aucune session trouvée',
|
||||||
? 'Aucune session de type ${_filterType!.displayName}'
|
style: TextStyle(
|
||||||
: 'Commencez par analyser une cible',
|
fontSize: 17,
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
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,
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -124,50 +301,29 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent() {
|
Widget _buildContent(bool isDark) {
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _loadSessions,
|
onRefresh: _loadSessions,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Chart section
|
if (_sessions.length >= 2 && _selectedDateRange == null)
|
||||||
if (_sessions.length >= 2)
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
child: HistoryChart(sessions: _sessions),
|
child: HistoryChart(sessions: _sessions),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Filter indicator
|
|
||||||
if (_filterType != null)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppConstants.defaultPadding),
|
|
||||||
child: Chip(
|
|
||||||
label: Text('Filtre: ${_filterType!.displayName}'),
|
|
||||||
deleteIcon: const Icon(Icons.close, size: 18),
|
|
||||||
onDeleted: () {
|
|
||||||
setState(() => _filterType = null);
|
|
||||||
_loadSessions();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Sessions list
|
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
|
||||||
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,
|
||||||
@@ -182,29 +338,31 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
void _openSessionDetail(Session session) async {
|
void _openSessionDetail(Session session) async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(builder: (_) => SessionDetailScreen(session: session)),
|
||||||
builder: (_) => SessionDetailScreen(session: session),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
_loadSessions(); // Refresh in case session was deleted
|
_loadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteSession(Session session) async {
|
Future<void> _deleteSession(Session session) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Supprimer'),
|
title: const Text('Supprimer la session'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?',
|
'Voulez-vous supprimer définitivement la session du ${DateFormat('dd/MM/yyyy à HH:mm').format(session.createdAt)} ?',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Annuler'),
|
child: const Text('Annuler'),
|
||||||
),
|
),
|
||||||
TextButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -215,20 +373,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.deleteSession(session.id);
|
await repository.deleteSession(session.id);
|
||||||
_loadSessions();
|
_loadSessions();
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Session supprimee')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
debugPrint('Erreur suppression: $e');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
/// Écran de détail d'une session.
|
|
||||||
///
|
|
||||||
/// Affiche la visualisation complète d'une session sauvegardée :
|
|
||||||
/// image de la cible avec overlay des impacts, scores recalculés,
|
|
||||||
/// statistiques de groupement et lien vers les statistiques détaillées.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -12,6 +5,8 @@ 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 '../../data/models/session.dart';
|
import '../../data/models/session.dart';
|
||||||
|
import '../../data/models/target_analysis.dart';
|
||||||
|
import '../../data/models/target_type.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.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';
|
||||||
@@ -20,7 +15,7 @@ import '../analysis/widgets/score_card.dart';
|
|||||||
import '../analysis/widgets/grouping_stats.dart';
|
import '../analysis/widgets/grouping_stats.dart';
|
||||||
import '../statistics/statistics_screen.dart';
|
import '../statistics/statistics_screen.dart';
|
||||||
|
|
||||||
class SessionDetailScreen extends StatelessWidget {
|
class SessionDetailScreen extends StatefulWidget {
|
||||||
final Session session;
|
final Session session;
|
||||||
|
|
||||||
const SessionDetailScreen({
|
const SessionDetailScreen({
|
||||||
@@ -28,33 +23,40 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
required this.session,
|
required this.session,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionDetailScreenState extends State<SessionDetailScreen> {
|
||||||
|
int _currentTargetIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scoreCalculator = context.read<ScoreCalculatorService>();
|
final scoreCalculator = context.read<ScoreCalculatorService>();
|
||||||
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
|
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
|
||||||
|
|
||||||
|
final currentAnalysis = widget.session.analyses[_currentTargetIndex];
|
||||||
|
|
||||||
final scoreResult = scoreCalculator.calculateScores(
|
final scoreResult = scoreCalculator.calculateScores(
|
||||||
shots: session.shots,
|
shots: currentAnalysis.shots,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
targetRadius: session.targetRadius ?? 0.4,
|
targetRadius: currentAnalysis.targetRadius ?? 0.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots);
|
final groupingResult = groupingAnalyzer.analyzeGrouping(currentAnalysis.shots);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(widget.session.weapon),
|
||||||
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.analytics),
|
icon: const Icon(Icons.analytics),
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => StatisticsScreen(singleSession: session),
|
builder: (_) => StatisticsScreen(singleSession: widget.session),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tooltip: 'Statistiques',
|
tooltip: 'Statistiques',
|
||||||
@@ -70,15 +72,22 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// Weapon and session overview
|
||||||
|
_buildSessionHeader(context),
|
||||||
|
|
||||||
|
// Target selector if multiple targets
|
||||||
|
if (widget.session.targetCount > 1)
|
||||||
|
_buildTargetSelector(),
|
||||||
|
|
||||||
// Target image with overlay
|
// Target image with overlay
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (File(session.imagePath).existsSync())
|
if (File(currentAnalysis.imagePath).existsSync())
|
||||||
Image.file(
|
Image.file(
|
||||||
File(session.imagePath),
|
File(currentAnalysis.imagePath),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -89,14 +98,14 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TargetOverlay(
|
TargetOverlay(
|
||||||
shots: session.shots,
|
shots: currentAnalysis.shots,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
targetRadius: session.targetRadius ?? 0.4,
|
targetRadius: currentAnalysis.targetRadius ?? 0.4,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
groupingCenterX: session.groupingCenterX,
|
groupingCenterX: currentAnalysis.groupingCenterX,
|
||||||
groupingCenterY: session.groupingCenterY,
|
groupingCenterY: currentAnalysis.groupingCenterY,
|
||||||
groupingDiameter: session.groupingDiameter,
|
groupingDiameter: currentAnalysis.groupingDiameter,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -107,31 +116,37 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Session info
|
// Analysis Info
|
||||||
_buildSessionInfo(context),
|
_buildAnalysisInfo(context, currentAnalysis),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Score card
|
// Score card for current target
|
||||||
ScoreCard(
|
ScoreCard(
|
||||||
totalScore: session.totalScore,
|
totalScore: currentAnalysis.totalScore,
|
||||||
shotCount: session.shotCount,
|
shotCount: currentAnalysis.shotCount,
|
||||||
scoreResult: scoreResult,
|
scoreResult: scoreResult,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Grouping stats
|
// Grouping stats for current target
|
||||||
if (session.shotCount > 1)
|
if (currentAnalysis.shotCount > 1)
|
||||||
GroupingStats(
|
GroupingStats(
|
||||||
groupingResult: groupingResult,
|
groupingResult: groupingResult,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Notes
|
// Notes for current target
|
||||||
if (session.notes != null && session.notes!.isNotEmpty) ...[
|
if (currentAnalysis.notes != null && currentAnalysis.notes!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildNotesCard(context),
|
_buildNotesCard(context, currentAnalysis.notes!),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Session notes
|
||||||
|
if (widget.session.notes != null && widget.session.notes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildNotesCard(context, widget.session.notes!, title: 'Notes de session'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -142,16 +157,83 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSessionInfo(BuildContext context) {
|
Widget _buildSessionHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat('EEEE dd MMMM yyyy', 'fr_FR').format(widget.session.createdAt),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
DateFormat('HH:mm').format(widget.session.createdAt),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Score Total: ${widget.session.totalScore}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryColor),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${widget.session.totalShots} tirs au total',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTargetSelector() {
|
||||||
|
return Container(
|
||||||
|
height: 50,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: widget.session.targetCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final isSelected = _currentTargetIndex == index;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text('Cible ${index + 1}'),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setState(() => _currentTargetIndex = index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnalysisInfo(BuildContext context, TargetAnalysis analysis) {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
session.targetType == session.targetType
|
analysis.targetType == TargetType.concentric ? Icons.track_changes : Icons.person,
|
||||||
? Icons.track_changes
|
|
||||||
: Icons.person,
|
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -160,14 +242,11 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
session.targetType.displayName,
|
analysis.targetType.displayName,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR')
|
'Cible ${_currentTargetIndex + 1} sur ${widget.session.targetCount}',
|
||||||
.format(session.createdAt),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -179,7 +258,7 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNotesCard(BuildContext context) {
|
Widget _buildNotesCard(BuildContext context, String notes, {String title = 'Notes'}) {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
@@ -188,18 +267,16 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.notes, color: AppTheme.primaryColor),
|
const Icon(Icons.notes, color: AppTheme.primaryColor, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Notes',
|
title,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Text(session.notes!),
|
Text(notes),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -211,7 +288,7 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Supprimer'),
|
title: const Text('Supprimer'),
|
||||||
content: const Text('Voulez-vous vraiment supprimer cette session?'),
|
content: const Text('Voulez-vous vraiment supprimer cette session entière ?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
@@ -231,20 +308,17 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
if (confirmed == true && context.mounted) {
|
if (confirmed == true && context.mounted) {
|
||||||
try {
|
try {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.deleteSession(session.id);
|
await repository.deleteSession(widget.session.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Session supprimee')),
|
const SnackBar(content: Text('Session supprimée')),
|
||||||
);
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
|
||||||
content: Text('Erreur: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class HistoryChart extends StatelessWidget {
|
|||||||
return touchedSpots.map((spot) {
|
return touchedSpots.map((spot) {
|
||||||
final session = displaySessions[spot.x.toInt()];
|
final session = displaySessions[spot.x.toInt()];
|
||||||
return LineTooltipItem(
|
return LineTooltipItem(
|
||||||
'Score: ${session.totalScore}\n${session.shotCount} tirs',
|
'Score: ${session.totalScore}\n${session.totalShots} tirs',
|
||||||
const TextStyle(
|
const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
/// Item de liste représentant une session.
|
|
||||||
///
|
|
||||||
/// Affiche les informations clés d'une session (date, type de cible,
|
|
||||||
/// score, nombre de tirs) avec miniature de l'image et actions de suppression.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import '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';
|
||||||
import '../../../data/models/target_type.dart';
|
|
||||||
|
|
||||||
class SessionListItem extends StatelessWidget {
|
class SessionListItem extends StatelessWidget {
|
||||||
final Session session;
|
final Session session;
|
||||||
@@ -25,130 +19,178 @@ class SessionListItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
clipBehavior: Clip.antiAlias,
|
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||||
child: InkWell(
|
final textMuted = isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted;
|
||||||
|
|
||||||
|
// Calcul de la couleur du score selon la moyenne par tir
|
||||||
|
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||||
|
final avg = session.averageScore;
|
||||||
|
Color scoreColor = primaryColor;
|
||||||
|
if (avg >= 9.0) {
|
||||||
|
scoreColor = AppTheme.secondaryColor;
|
||||||
|
} else if (avg >= 7.5) {
|
||||||
|
scoreColor = primaryColor;
|
||||||
|
} else {
|
||||||
|
scoreColor = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
final formattedDate = DateFormat('dd/MM/yyyy • HH:mm', 'fr_FR').format(session.createdAt);
|
||||||
|
|
||||||
|
return GlassContainer(
|
||||||
|
borderRadius: 18,
|
||||||
|
blur: 14,
|
||||||
|
glowColor: scoreColor,
|
||||||
|
borderColor: isDark ? scoreColor.withValues(alpha: 0.2) : scoreColor.withValues(alpha: 0.15),
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Thumbnail
|
// Aperçu de la cible avec bordure nette
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: SizedBox(
|
child: Container(
|
||||||
width: 60,
|
width: 56,
|
||||||
height: 60,
|
height: 56,
|
||||||
child: _buildThumbnail(),
|
decoration: BoxDecoration(
|
||||||
|
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
child: _buildThumbnail(isDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
|
||||||
// Info
|
// Informations de la session
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_getTargetIcon(),
|
|
||||||
size: 16,
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
Text(
|
||||||
session.targetType.displayName,
|
session.weapon,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 15,
|
||||||
),
|
fontWeight: FontWeight.w700,
|
||||||
),
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
if (session.notes != null && session.notes!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
session.notes!,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Colors.grey[600],
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
formattedDate,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${session.distance}m',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'${session.targetCount} cible${session.targetCount > 1 ? 's' : ''} • ${session.totalShots} tirs',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Score
|
// Score et moyenne stylisés façon cyber HUD
|
||||||
Column(
|
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,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${session.totalScore}',
|
'${session.totalScore}',
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 18,
|
||||||
color: AppTheme.primaryColor,
|
fontWeight: FontWeight.w900,
|
||||||
|
color: scoreColor,
|
||||||
|
letterSpacing: -0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${session.shotCount} tirs',
|
'Moy. ${session.averageScore.toStringAsFixed(1)}',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: scoreColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Delete button
|
// Bouton supprimer
|
||||||
if (onDelete != null)
|
if (onDelete != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
onPressed: onDelete,
|
onPressed: onDelete,
|
||||||
color: Colors.grey,
|
color: textMuted,
|
||||||
iconSize: 20,
|
iconSize: 20,
|
||||||
|
tooltip: 'Supprimer',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildThumbnail() {
|
Widget _buildThumbnail(bool isDark) {
|
||||||
final file = File(session.imagePath);
|
if (session.analyses.isEmpty) return _buildPlaceholder(isDark);
|
||||||
|
|
||||||
|
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(),
|
errorBuilder: (_, _, _) => _buildPlaceholder(isDark),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildPlaceholder();
|
return _buildPlaceholder(isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaceholder() {
|
Widget _buildPlaceholder(bool isDark) {
|
||||||
return Container(
|
return Icon(
|
||||||
color: Colors.grey[200],
|
Icons.track_changes,
|
||||||
child: Icon(
|
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
||||||
_getTargetIcon(),
|
size: 24,
|
||||||
color: Colors.grey[400],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getTargetIcon() {
|
|
||||||
switch (session.targetType) {
|
|
||||||
case TargetType.concentric:
|
|
||||||
return Icons.track_changes;
|
|
||||||
case TargetType.silhouette:
|
|
||||||
return Icons.person;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
/// Widget carte réutilisable pour afficher une statistique.
|
|
||||||
///
|
|
||||||
/// Affiche une icône, un titre et une valeur avec une couleur personnalisable.
|
|
||||||
/// Utilisé sur l'écran d'accueil pour les statistiques rapides.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants/app_constants.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../core/widgets/glass_container.dart';
|
||||||
|
|
||||||
|
/// Widget carte télémétrique moderne avec effet Glassmorphism givré.
|
||||||
class StatsCard extends StatelessWidget {
|
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,
|
||||||
@@ -19,33 +16,82 @@ 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) {
|
||||||
return Card(
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
child: Padding(
|
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
|
||||||
|
return GlassContainer(
|
||||||
|
borderRadius: 18,
|
||||||
|
blur: 14,
|
||||||
|
glowColor: color,
|
||||||
|
borderColor: isDark ? color.withValues(alpha: 0.22) : color.withValues(alpha: 0.18),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: color, size: 32),
|
Row(
|
||||||
const SizedBox(height: 8),
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
Text(
|
children: [
|
||||||
value,
|
Container(
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
padding: const EdgeInsets.all(8),
|
||||||
fontWeight: FontWeight.bold,
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
|
||||||
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
child: Icon(icon, color: color, size: 20),
|
||||||
Text(
|
),
|
||||||
title,
|
if (subtitle != null)
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
Container(
|
||||||
color: Colors.grey[600],
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: textSecondary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: textSecondary,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../data/models/target_analysis.dart';
|
||||||
|
|
||||||
|
class SessionProvider extends ChangeNotifier {
|
||||||
|
DateTime? _sessionDate;
|
||||||
|
DateTime? get sessionDate => _sessionDate;
|
||||||
|
String? _currentWeaponName;
|
||||||
|
String? _currentWeaponId;
|
||||||
|
String? _currentWeaponCaliber;
|
||||||
|
int _shotsPerTarget = 5;
|
||||||
|
int _distance = 25;
|
||||||
|
String? _activeSessionId;
|
||||||
|
final List<TargetAnalysis> _currentAnalyses = [];
|
||||||
|
bool _isSessionActive = false;
|
||||||
|
|
||||||
|
String? get currentWeapon => _currentWeaponName;
|
||||||
|
String? get currentWeaponId => _currentWeaponId;
|
||||||
|
|
||||||
|
/// Calibre de l'arme de la session. Contrairement au nom d'arme, il ne
|
||||||
|
/// designe pas l'utilisateur : c'est la seule donnee d'arme envoyee au
|
||||||
|
/// backend d'entrainement (le diametre des trous en depend).
|
||||||
|
String? get currentWeaponCaliber => _currentWeaponCaliber;
|
||||||
|
int get shotsPerTarget => _shotsPerTarget;
|
||||||
|
int get distance => _distance;
|
||||||
|
bool get isSessionActive => _isSessionActive;
|
||||||
|
String? get activeSessionId => _activeSessionId;
|
||||||
|
List<TargetAnalysis> get currentAnalyses => List.unmodifiable(_currentAnalyses);
|
||||||
|
|
||||||
|
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
|
||||||
|
int get targetCount => _currentAnalyses.length;
|
||||||
|
|
||||||
|
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, String? caliber, int distance = 25, DateTime? date}) {
|
||||||
|
_currentWeaponName = weaponName;
|
||||||
|
_currentWeaponId = weaponId;
|
||||||
|
_currentWeaponCaliber = caliber;
|
||||||
|
_shotsPerTarget = shots;
|
||||||
|
_distance = distance;
|
||||||
|
_sessionDate = date ?? DateTime.now();
|
||||||
|
_activeSessionId = sessionId;
|
||||||
|
_currentAnalyses.clear();
|
||||||
|
_isSessionActive = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addAnalysis(TargetAnalysis analysis) {
|
||||||
|
_currentAnalyses.add(analysis);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void endSession() {
|
||||||
|
_isSessionActive = false;
|
||||||
|
_activeSessionId = null;
|
||||||
|
_currentWeaponId = null;
|
||||||
|
_currentWeaponName = null;
|
||||||
|
_currentWeaponCaliber = null;
|
||||||
|
_currentAnalyses.clear();
|
||||||
|
_distance = 25;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../data/models/weapon.dart';
|
||||||
|
import '../../data/repositories/session_repository.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
import '../capture/capture_screen.dart';
|
||||||
|
import '../garage/weapon_list_screen.dart';
|
||||||
|
|
||||||
|
class SessionSetupScreen extends StatefulWidget {
|
||||||
|
const SessionSetupScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SessionSetupScreen> createState() => _SessionSetupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
int _shotsPerTarget = 5;
|
||||||
|
int _distance = 25;
|
||||||
|
List<Weapon> _availableWeapons = [];
|
||||||
|
Weapon? _selectedWeapon;
|
||||||
|
bool _isLoadingWeapons = true;
|
||||||
|
|
||||||
|
DateTime _selectedDate = DateTime.now();
|
||||||
|
|
||||||
|
/// Valeur sentinelle de l'item "Ajouter une nouvelle arme" du menu deroulant.
|
||||||
|
static const String _addWeaponValue = '__add_weapon__';
|
||||||
|
|
||||||
|
final GlobalKey<FormFieldState<String>> _weaponFieldKey =
|
||||||
|
GlobalKey<FormFieldState<String>>();
|
||||||
|
|
||||||
|
static const List<int> _presetDistances = [10, 15, 25, 50, 100, 200];
|
||||||
|
static const List<int> _presetShots = [3, 5, 10, 15, 20, 30, 50];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWeapons() async {
|
||||||
|
setState(() => _isLoadingWeapons = true);
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final weapons = await repository.getWeapons();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_availableWeapons = weapons;
|
||||||
|
|
||||||
|
// Les armes rechargees sont de nouvelles instances (Weapon n'a pas
|
||||||
|
// d'operator ==) et l'arme selectionnee a pu etre supprimee : on
|
||||||
|
// re-resout la selection par identifiant.
|
||||||
|
final previousId = _selectedWeapon?.id;
|
||||||
|
_selectedWeapon = null;
|
||||||
|
for (final weapon in weapons) {
|
||||||
|
if (weapon.id == previousId) {
|
||||||
|
_selectedWeapon = weapon;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedWeapon == null && weapons.isNotEmpty) {
|
||||||
|
_selectedWeapon = weapons.first;
|
||||||
|
_updateSettingsForWeapon(_selectedWeapon!);
|
||||||
|
}
|
||||||
|
_isLoadingWeapons = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openArmory() async {
|
||||||
|
final knownIds = _availableWeapons.map((w) => w.id).toSet();
|
||||||
|
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await _loadWeapons();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Selectionne automatiquement l'arme qui vient d'etre ajoutee.
|
||||||
|
Weapon? added;
|
||||||
|
for (final weapon in _availableWeapons) {
|
||||||
|
if (!knownIds.contains(weapon.id)) {
|
||||||
|
added = weapon;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added != null) {
|
||||||
|
final newWeapon = added;
|
||||||
|
setState(() {
|
||||||
|
_selectedWeapon = newWeapon;
|
||||||
|
_updateSettingsForWeapon(newWeapon);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSettingsForWeapon(Weapon weapon) {
|
||||||
|
_shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
|
||||||
|
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDate() async {
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _selectedDate,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
);
|
||||||
|
if (picked != null && picked != _selectedDate) {
|
||||||
|
setState(() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
_selectedDate = DateTime(
|
||||||
|
picked.year,
|
||||||
|
picked.month,
|
||||||
|
picked.day,
|
||||||
|
now.hour,
|
||||||
|
now.minute,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startSession() {
|
||||||
|
if (_formKey.currentState!.validate() && _selectedWeapon != null) {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
final sessionProvider = context.read<SessionProvider>();
|
||||||
|
|
||||||
|
final sessionId = repository.generateId();
|
||||||
|
|
||||||
|
sessionProvider.startSession(
|
||||||
|
_selectedWeapon!.displayName,
|
||||||
|
_shotsPerTarget,
|
||||||
|
sessionId,
|
||||||
|
weaponId: _selectedWeapon!.id,
|
||||||
|
caliber: _selectedWeapon!.caliber,
|
||||||
|
distance: _distance,
|
||||||
|
date: _selectedDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const CaptureScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final formattedDate = DateFormat('dd MMMM yyyy • HH:mm', 'fr_FR').format(_selectedDate);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Configuration de Session'),
|
||||||
|
),
|
||||||
|
body: _isLoadingWeapons
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (_availableWeapons.isEmpty)
|
||||||
|
_buildEmptyArmoryState(isDark)
|
||||||
|
else ...[
|
||||||
|
_buildWeaponAndDateSection(isDark, formattedDate),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildDistanceSection(isDark),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildShotsSection(isDark),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_buildStartButton(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyArmoryState(bool isDark) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.errorColor.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
size: 52,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Armurerie vide',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Ajoutez au moins une arme dans votre armurerie pour débuter une séance.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _openArmory,
|
||||||
|
icon: const Icon(Icons.shield),
|
||||||
|
label: const Text('Aller à l\'armurerie'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWeaponAndDateSection(bool isDark, String formattedDate) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.tune, color: AppTheme.primaryColor, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Arme & Date',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
key: _weaponFieldKey,
|
||||||
|
initialValue: _selectedWeapon?.id,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Arme utilisée',
|
||||||
|
prefixIcon: Icon(Icons.shield_outlined),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
..._availableWeapons.map(
|
||||||
|
(w) => DropdownMenuItem(
|
||||||
|
value: w.id,
|
||||||
|
child: Text('${w.displayName} (${w.caliber})'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: _addWeaponValue,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.add_circle_outline,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Ajouter une nouvelle arme',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
|
||||||
|
if (value == _addWeaponValue) {
|
||||||
|
// L'item d'action n'est pas une arme : on restaure aussitot
|
||||||
|
// la selection precedente avant d'ouvrir l'armurerie.
|
||||||
|
_weaponFieldKey.currentState?.didChange(_selectedWeapon?.id);
|
||||||
|
_openArmory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final weapon in _availableWeapons) {
|
||||||
|
if (weapon.id == value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedWeapon = weapon;
|
||||||
|
_updateSettingsForWeapon(weapon);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InkWell(
|
||||||
|
onTap: _pickDate,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: TextFormField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Date de séance',
|
||||||
|
prefixIcon: Icon(Icons.calendar_today_outlined),
|
||||||
|
),
|
||||||
|
controller: TextEditingController(text: formattedDate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDistanceSection(bool isDark) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.straighten, color: AppTheme.secondaryColor, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Distance de Tir',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.secondaryColor.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${_distance}m',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppTheme.secondaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _presetDistances.map((d) {
|
||||||
|
final isSelected = _distance == d;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text('${d}m'),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) setState(() => _distance = d);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Slider(
|
||||||
|
value: _distance.toDouble(),
|
||||||
|
min: 5,
|
||||||
|
max: 300,
|
||||||
|
divisions: 59,
|
||||||
|
activeColor: AppTheme.secondaryColor,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() => _distance = val.round());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildShotsSection(bool isDark) {
|
||||||
|
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.ads_click, color: primaryColor, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Tirs par Cible',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: primaryColor.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$_shotsPerTarget tirs',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _presetShots.map((s) {
|
||||||
|
final isSelected = _shotsPerTarget == s;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text('$s coups'),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) setState(() => _shotsPerTarget = s);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Slider(
|
||||||
|
value: _shotsPerTarget.toDouble(),
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
divisions: 49,
|
||||||
|
activeColor: primaryColor,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() => _shotsPerTarget = val.round());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStartButton() {
|
||||||
|
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
primaryColor,
|
||||||
|
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0)).toColor(),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: primaryColor.withValues(alpha: 0.35),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
|
||||||
|
? null
|
||||||
|
: _startSession,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded, size: 26),
|
||||||
|
label: const Text(
|
||||||
|
'DÉMARRER LA SESSION',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/// Point d'entrée du didacticiel : lance une visite guidée par-dessus l'écran
|
||||||
|
/// courant (route transparente) et mémorise qu'elle a été vue.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'tutorial_provider.dart';
|
||||||
|
import 'tutorial_step.dart';
|
||||||
|
import 'widgets/tutorial_overlay.dart';
|
||||||
|
|
||||||
|
class TutorialCoach {
|
||||||
|
const TutorialCoach._();
|
||||||
|
|
||||||
|
static bool _isShowing = false;
|
||||||
|
|
||||||
|
/// Une visite est-elle déjà affichée ? (évite les doubles déclenchements)
|
||||||
|
static bool get isShowing => _isShowing;
|
||||||
|
|
||||||
|
/// Joue la visite [tourId] uniquement si elle n'a jamais été vue.
|
||||||
|
///
|
||||||
|
/// [stepsBuilder] n'est évalué que si la visite doit réellement démarrer :
|
||||||
|
/// les clés des widgets sont ainsi lues au dernier moment.
|
||||||
|
static Future<void> maybeStart(
|
||||||
|
BuildContext context, {
|
||||||
|
required String tourId,
|
||||||
|
required List<TutorialStep> Function() stepsBuilder,
|
||||||
|
}) async {
|
||||||
|
if (_isShowing) return;
|
||||||
|
if (!context.read<TutorialProvider>().shouldRun(tourId)) return;
|
||||||
|
await start(context, tourId: tourId, steps: stepsBuilder());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joue la visite [tourId], même si elle a déjà été vue.
|
||||||
|
static Future<void> start(
|
||||||
|
BuildContext context, {
|
||||||
|
required String tourId,
|
||||||
|
required List<TutorialStep> steps,
|
||||||
|
}) async {
|
||||||
|
if (_isShowing || steps.isEmpty) return;
|
||||||
|
|
||||||
|
final provider = context.read<TutorialProvider>();
|
||||||
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
_isShowing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.push(
|
||||||
|
PageRouteBuilder<void>(
|
||||||
|
opaque: false,
|
||||||
|
barrierDismissible: false,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
transitionDuration: const Duration(milliseconds: 240),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 180),
|
||||||
|
pageBuilder: (routeContext, animation, _) => FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: TutorialOverlay(
|
||||||
|
steps: steps,
|
||||||
|
onFinished: () => Navigator.of(routeContext).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_isShowing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await provider.complete(tourId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/// État global du didacticiel : quelles visites guidées restent à jouer.
|
||||||
|
///
|
||||||
|
/// Le provider est chargé au démarrage ; tant que les préférences ne sont pas
|
||||||
|
/// lues, aucune visite n'est déclenchée (évite un flash d'overlay au lancement).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../services/tutorial_service.dart';
|
||||||
|
|
||||||
|
class TutorialProvider with ChangeNotifier {
|
||||||
|
TutorialProvider({TutorialService? service})
|
||||||
|
: _service = service ?? TutorialService() {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
final TutorialService _service;
|
||||||
|
|
||||||
|
final Set<String> _completed = {};
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
/// `true` une fois les préférences lues.
|
||||||
|
bool get isLoaded => _loaded;
|
||||||
|
|
||||||
|
/// `true` si l'utilisateur a déjà terminé (ou passé) la visite d'accueil.
|
||||||
|
bool get hasSeenIntro => _completed.contains(TutorialTours.home);
|
||||||
|
|
||||||
|
/// Lit les visites déjà vues. Les visites terminées pendant le chargement
|
||||||
|
/// sont conservées (le résultat est fusionné, jamais écrasé).
|
||||||
|
Future<void> load() async {
|
||||||
|
_completed.addAll(await _service.loadCompletedTours());
|
||||||
|
_loaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Faut-il jouer la visite [tourId] ?
|
||||||
|
bool shouldRun(String tourId) => _loaded && !_completed.contains(tourId);
|
||||||
|
|
||||||
|
/// Marque une visite comme vue (terminée ou passée) : elle ne rejouera plus.
|
||||||
|
Future<void> complete(String tourId) async {
|
||||||
|
if (_completed.add(tourId)) {
|
||||||
|
notifyListeners();
|
||||||
|
await _service.markCompleted(tourId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relance le didacticiel depuis les paramètres : toutes les visites
|
||||||
|
/// redeviennent disponibles et rejoueront dès l'affichage de leur écran.
|
||||||
|
Future<void> restart() async {
|
||||||
|
await _service.resetAll();
|
||||||
|
_completed.clear();
|
||||||
|
_loaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/// Modèle d'une étape de didacticiel (visite guidée).
|
||||||
|
///
|
||||||
|
/// Une étape met en avant un élément de l'écran (le « spot ») grâce à une
|
||||||
|
/// [GlobalKey] posée sur le widget concerné, et affiche une bulle explicative
|
||||||
|
/// accompagnée, si besoin, d'une main animée qui mime le geste attendu
|
||||||
|
/// (tap, appui long, pincement…) comme dans les applications mobiles.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Geste mimé par la main animée pendant l'étape.
|
||||||
|
enum TutorialGesture {
|
||||||
|
/// Aucune main animée : simple explication.
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Un appui simple.
|
||||||
|
tap,
|
||||||
|
|
||||||
|
/// Deux appuis rapprochés.
|
||||||
|
doubleTap,
|
||||||
|
|
||||||
|
/// Un appui maintenu.
|
||||||
|
longPress,
|
||||||
|
|
||||||
|
/// Un appui maintenu suivi d'un déplacement du doigt.
|
||||||
|
drag,
|
||||||
|
|
||||||
|
/// Deux doigts qui s'écartent puis se rapprochent (zoom).
|
||||||
|
pinch,
|
||||||
|
|
||||||
|
/// Un doigt qui balaie l'écran vers le haut.
|
||||||
|
swipeUp,
|
||||||
|
|
||||||
|
/// Un doigt qui balaie l'écran horizontalement.
|
||||||
|
swipeHorizontal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forme du trou de lumière découpé dans le voile sombre.
|
||||||
|
enum TutorialHighlightShape { rounded, circle }
|
||||||
|
|
||||||
|
class TutorialStep {
|
||||||
|
/// Clé posée sur le widget à mettre en avant.
|
||||||
|
///
|
||||||
|
/// `null` (ou clé non montée) => l'étape s'affiche comme une carte centrée,
|
||||||
|
/// utile pour les messages d'accueil et de fin.
|
||||||
|
final GlobalKey? targetKey;
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
/// Geste mimé par la main animée.
|
||||||
|
final TutorialGesture gesture;
|
||||||
|
|
||||||
|
final TutorialHighlightShape shape;
|
||||||
|
|
||||||
|
/// Marge ajoutée autour du widget mis en avant.
|
||||||
|
final double spotPadding;
|
||||||
|
|
||||||
|
/// Icône affichée dans la bulle explicative.
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
/// Force la position de la bulle (`true` = au-dessus du spot).
|
||||||
|
/// Par défaut, la bulle se place automatiquement du côté le plus dégagé.
|
||||||
|
final bool? preferTooltipAbove;
|
||||||
|
|
||||||
|
const TutorialStep({
|
||||||
|
this.targetKey,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
this.gesture = TutorialGesture.none,
|
||||||
|
this.shape = TutorialHighlightShape.rounded,
|
||||||
|
this.spotPadding = 8,
|
||||||
|
this.icon,
|
||||||
|
this.preferTooltipAbove,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
/// Main animée du didacticiel : mime le geste attendu par l'utilisateur
|
||||||
|
/// (tap, appui long, glisser, pincement pour zoomer, balayage) comme dans les
|
||||||
|
/// tutoriels des applications mobiles.
|
||||||
|
///
|
||||||
|
/// Tout est dessiné au CustomPainter : pas d'asset, la couleur suit l'accent
|
||||||
|
/// du thème et le rendu reste lisible sur fond clair comme sur fond sombre
|
||||||
|
/// grâce au cœur blanc entouré d'un halo coloré.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../tutorial_step.dart';
|
||||||
|
|
||||||
|
class TutorialHand extends StatefulWidget {
|
||||||
|
final TutorialGesture gesture;
|
||||||
|
final Color color;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const TutorialHand({
|
||||||
|
super.key,
|
||||||
|
required this.gesture,
|
||||||
|
required this.color,
|
||||||
|
this.size = 96,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TutorialHand> createState() => _TutorialHandState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TutorialHandState extends State<TutorialHand>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: _durationFor(widget.gesture),
|
||||||
|
)..repeat();
|
||||||
|
|
||||||
|
static Duration _durationFor(TutorialGesture gesture) {
|
||||||
|
switch (gesture) {
|
||||||
|
case TutorialGesture.pinch:
|
||||||
|
return const Duration(milliseconds: 2400);
|
||||||
|
case TutorialGesture.longPress:
|
||||||
|
case TutorialGesture.drag:
|
||||||
|
return const Duration(milliseconds: 2200);
|
||||||
|
case TutorialGesture.swipeUp:
|
||||||
|
case TutorialGesture.swipeHorizontal:
|
||||||
|
return const Duration(milliseconds: 1800);
|
||||||
|
default:
|
||||||
|
return const Duration(milliseconds: 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(TutorialHand oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.gesture != widget.gesture) {
|
||||||
|
_controller
|
||||||
|
..stop()
|
||||||
|
..duration = _durationFor(widget.gesture)
|
||||||
|
..repeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.gesture == TutorialGesture.none) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return IgnorePointer(
|
||||||
|
child: SizedBox(
|
||||||
|
width: widget.size,
|
||||||
|
height: widget.size,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, _) => CustomPaint(
|
||||||
|
painter: _GesturePainter(
|
||||||
|
t: _controller.value,
|
||||||
|
gesture: widget.gesture,
|
||||||
|
color: widget.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GesturePainter extends CustomPainter {
|
||||||
|
final double t;
|
||||||
|
final TutorialGesture gesture;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
_GesturePainter({
|
||||||
|
required this.t,
|
||||||
|
required this.gesture,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
|
||||||
|
switch (gesture) {
|
||||||
|
case TutorialGesture.none:
|
||||||
|
return;
|
||||||
|
case TutorialGesture.tap:
|
||||||
|
_paintTap(canvas, size, center, pulses: 1);
|
||||||
|
case TutorialGesture.doubleTap:
|
||||||
|
_paintTap(canvas, size, center, pulses: 2);
|
||||||
|
case TutorialGesture.longPress:
|
||||||
|
_paintLongPress(canvas, size, center);
|
||||||
|
case TutorialGesture.drag:
|
||||||
|
_paintDrag(canvas, size, center);
|
||||||
|
case TutorialGesture.pinch:
|
||||||
|
_paintPinch(canvas, size, center);
|
||||||
|
case TutorialGesture.swipeUp:
|
||||||
|
_paintSwipe(canvas, size, center, vertical: true);
|
||||||
|
case TutorialGesture.swipeHorizontal:
|
||||||
|
_paintSwipe(canvas, size, center, vertical: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- Utilitaires
|
||||||
|
|
||||||
|
/// Onde triangulaire adoucie : 0 -> 1 -> 0 sur un cycle.
|
||||||
|
double _wave(double x) => Curves.easeInOut
|
||||||
|
.transform((x < 0.5 ? x * 2 : (1 - x) * 2).clamp(0.0, 1.0));
|
||||||
|
|
||||||
|
/// Doigt posé sur l'écran : cœur blanc + halo coloré.
|
||||||
|
void _paintFingertip(
|
||||||
|
Canvas canvas,
|
||||||
|
Offset position,
|
||||||
|
double radius, {
|
||||||
|
double opacity = 1.0,
|
||||||
|
}) {
|
||||||
|
canvas.drawCircle(
|
||||||
|
position,
|
||||||
|
radius * 2.1,
|
||||||
|
Paint()
|
||||||
|
..color = color.withValues(alpha: 0.22 * opacity)
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
|
||||||
|
);
|
||||||
|
canvas.drawCircle(
|
||||||
|
position,
|
||||||
|
radius,
|
||||||
|
Paint()..color = Colors.white.withValues(alpha: 0.95 * opacity),
|
||||||
|
);
|
||||||
|
canvas.drawCircle(
|
||||||
|
position,
|
||||||
|
radius,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.4
|
||||||
|
..color = color.withValues(alpha: 0.9 * opacity),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ondes concentriques émises au moment du contact.
|
||||||
|
void _paintRipple(
|
||||||
|
Canvas canvas,
|
||||||
|
Offset position,
|
||||||
|
double progress,
|
||||||
|
double maxRadius,
|
||||||
|
) {
|
||||||
|
if (progress <= 0 || progress >= 1) return;
|
||||||
|
canvas.drawCircle(
|
||||||
|
position,
|
||||||
|
6 + progress * maxRadius,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.5 * (1 - progress) + 0.6
|
||||||
|
..color = color.withValues(alpha: 0.55 * (1 - progress)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main « pointeur » (icône Material) dont l'index touche [tip].
|
||||||
|
void _paintHand(
|
||||||
|
Canvas canvas,
|
||||||
|
Offset tip,
|
||||||
|
double glyphSize, {
|
||||||
|
double press = 0,
|
||||||
|
}) {
|
||||||
|
final painter = TextPainter(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
text: TextSpan(
|
||||||
|
text: String.fromCharCode(Icons.touch_app_rounded.codePoint),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: glyphSize,
|
||||||
|
fontFamily: Icons.touch_app_rounded.fontFamily,
|
||||||
|
package: Icons.touch_app_rounded.fontPackage,
|
||||||
|
color: Colors.white.withValues(alpha: 0.96),
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)..layout();
|
||||||
|
|
||||||
|
// Le bout de l'index de l'icône se situe environ à 32 % / 16 % du glyphe.
|
||||||
|
final origin = tip -
|
||||||
|
Offset(painter.width * 0.32, painter.height * 0.16) +
|
||||||
|
Offset(glyphSize * 0.03 * press, glyphSize * 0.06 * press);
|
||||||
|
painter.paint(canvas, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Gestes
|
||||||
|
|
||||||
|
void _paintTap(
|
||||||
|
Canvas canvas,
|
||||||
|
Size size,
|
||||||
|
Offset center, {
|
||||||
|
required int pulses,
|
||||||
|
}) {
|
||||||
|
final cycle = (t * pulses) % 1.0;
|
||||||
|
final press = _wave((cycle / 0.4).clamp(0.0, 1.0));
|
||||||
|
|
||||||
|
_paintRipple(canvas, center, cycle, size.width * 0.34);
|
||||||
|
_paintRipple(
|
||||||
|
canvas, center, (cycle - 0.25).clamp(0.0, 1.0), size.width * 0.34);
|
||||||
|
_paintFingertip(canvas, center, size.width * 0.055 + 2 * press,
|
||||||
|
opacity: 0.35 + 0.65 * press);
|
||||||
|
_paintHand(canvas, center, size.width * 0.58, press: press);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintLongPress(Canvas canvas, Size size, Offset center) {
|
||||||
|
final hold = Curves.easeOut.transform((t / 0.75).clamp(0.0, 1.0));
|
||||||
|
final radius = size.width * 0.24;
|
||||||
|
|
||||||
|
canvas.drawCircle(
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 3
|
||||||
|
..color = Colors.white.withValues(alpha: 0.22),
|
||||||
|
);
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
-math.pi / 2,
|
||||||
|
2 * math.pi * hold,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 3.4
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = color.withValues(alpha: 0.95),
|
||||||
|
);
|
||||||
|
|
||||||
|
_paintFingertip(canvas, center, size.width * 0.06);
|
||||||
|
_paintHand(canvas, center, size.width * 0.58, press: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintDrag(Canvas canvas, Size size, Offset center) {
|
||||||
|
// 0 -> 0.35 : appui maintenu ; puis déplacement aller-retour du doigt.
|
||||||
|
final hold = Curves.easeOut.transform((t / 0.35).clamp(0.0, 1.0));
|
||||||
|
final travel =
|
||||||
|
t <= 0.35 ? 0.0 : _wave(((t - 0.35) / 0.65).clamp(0.0, 1.0));
|
||||||
|
final start = center - Offset(size.width * 0.16, 0);
|
||||||
|
final position = start + Offset(size.width * 0.32 * travel, 0);
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
start,
|
||||||
|
position,
|
||||||
|
Paint()
|
||||||
|
..strokeWidth = 3
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = color.withValues(alpha: 0.35),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hold < 1) {
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: start, radius: size.width * 0.16),
|
||||||
|
-math.pi / 2,
|
||||||
|
2 * math.pi * hold,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 3
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = color.withValues(alpha: 0.9),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_paintFingertip(canvas, position, size.width * 0.06);
|
||||||
|
_paintHand(canvas, position, size.width * 0.58, press: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintPinch(Canvas canvas, Size size, Offset center) {
|
||||||
|
// Les deux doigts s'écartent (zoom avant) puis se rapprochent.
|
||||||
|
final spread = _wave(t);
|
||||||
|
final direction = Offset(math.cos(-math.pi / 4), math.sin(-math.pi / 4));
|
||||||
|
final distance = size.width * (0.12 + 0.24 * spread);
|
||||||
|
final first = center + direction * distance;
|
||||||
|
final second = center - direction * distance;
|
||||||
|
final radius = size.width * 0.075;
|
||||||
|
|
||||||
|
// Ligne pointillée reliant les deux doigts.
|
||||||
|
final dashPaint = Paint()
|
||||||
|
..strokeWidth = 2
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = color.withValues(alpha: 0.45);
|
||||||
|
const dashes = 9;
|
||||||
|
for (int i = 0; i < dashes; i++) {
|
||||||
|
if (i.isOdd) continue;
|
||||||
|
final a = Offset.lerp(second, first, i / dashes)!;
|
||||||
|
final b = Offset.lerp(second, first, (i + 1) / dashes)!;
|
||||||
|
canvas.drawLine(a, b, dashPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chevrons indiquant le sens de l'écartement.
|
||||||
|
_paintChevron(canvas, first, direction, size.width * 0.06, spread);
|
||||||
|
_paintChevron(canvas, second, -direction, size.width * 0.06, spread);
|
||||||
|
|
||||||
|
_paintFingertip(canvas, first, radius);
|
||||||
|
_paintFingertip(canvas, second, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintChevron(
|
||||||
|
Canvas canvas,
|
||||||
|
Offset tip,
|
||||||
|
Offset direction,
|
||||||
|
double length,
|
||||||
|
double spread,
|
||||||
|
) {
|
||||||
|
final base = tip + direction * (length * 1.6);
|
||||||
|
final angle = math.atan2(direction.dy, direction.dx);
|
||||||
|
final paint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.6
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = color.withValues(alpha: 0.25 + 0.6 * spread);
|
||||||
|
|
||||||
|
for (final sign in [-1, 1]) {
|
||||||
|
final branch = angle + sign * 2.5;
|
||||||
|
canvas.drawLine(
|
||||||
|
base,
|
||||||
|
base + Offset(math.cos(branch), math.sin(branch)) * length,
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintSwipe(
|
||||||
|
Canvas canvas,
|
||||||
|
Size size,
|
||||||
|
Offset center, {
|
||||||
|
required bool vertical,
|
||||||
|
}) {
|
||||||
|
final progress = Curves.easeInOut.transform((t / 0.75).clamp(0.0, 1.0));
|
||||||
|
final fade = t > 0.75 ? 1 - ((t - 0.75) / 0.25) : 1.0;
|
||||||
|
final axis = vertical ? const Offset(0, -1) : const Offset(1, 0);
|
||||||
|
final amplitude = size.width * 0.28;
|
||||||
|
final start = center - axis * amplitude;
|
||||||
|
final position = start + axis * (2 * amplitude * progress);
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
start,
|
||||||
|
position,
|
||||||
|
Paint()
|
||||||
|
..strokeWidth = 3
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = color.withValues(alpha: 0.32 * fade),
|
||||||
|
);
|
||||||
|
_paintFingertip(canvas, position, size.width * 0.06, opacity: fade);
|
||||||
|
_paintHand(canvas, position, size.width * 0.58, press: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_GesturePainter old) =>
|
||||||
|
old.t != t || old.gesture != gesture || old.color != color;
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
/// Voile du didacticiel : assombrit l'écran, découpe un « trou de lumière »
|
||||||
|
/// autour de l'élément à découvrir, y anime une main qui mime le geste attendu
|
||||||
|
/// et affiche une bulle explicative avec la progression.
|
||||||
|
///
|
||||||
|
/// L'overlay est purement visuel : il n'exécute pas l'action à la place de
|
||||||
|
/// l'utilisateur. On avance d'une étape en touchant l'écran ou le bouton
|
||||||
|
/// « Suivant » ; « Passer » interrompt la visite.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../tutorial_step.dart';
|
||||||
|
import 'tutorial_hand.dart';
|
||||||
|
|
||||||
|
class TutorialOverlay extends StatefulWidget {
|
||||||
|
final List<TutorialStep> steps;
|
||||||
|
|
||||||
|
/// Appelé à la fin de la visite (terminée ou passée).
|
||||||
|
final VoidCallback onFinished;
|
||||||
|
|
||||||
|
const TutorialOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.steps,
|
||||||
|
required this.onFinished,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TutorialOverlay> createState() => _TutorialOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TutorialOverlayState extends State<TutorialOverlay>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _pulseController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1600),
|
||||||
|
)..repeat();
|
||||||
|
|
||||||
|
int _index = 0;
|
||||||
|
Rect? _spotRect;
|
||||||
|
late bool _ready = widget.steps.isNotEmpty && widget.steps[0].targetKey == null;
|
||||||
|
|
||||||
|
TutorialStep get _step => widget.steps[_index];
|
||||||
|
bool get _isLast => _index == widget.steps.length - 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pulseController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Amène l'élément visé à l'écran puis mesure sa position.
|
||||||
|
Future<void> _prepareStep() async {
|
||||||
|
final targetContext = _step.targetKey?.currentContext;
|
||||||
|
|
||||||
|
if (targetContext == null || !targetContext.mounted) {
|
||||||
|
if (mounted) setState(() { _spotRect = null; _ready = true; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Scrollable.ensureVisible(
|
||||||
|
targetContext,
|
||||||
|
alignment: 0.35,
|
||||||
|
duration: const Duration(milliseconds: 320),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignorer si l'élément n'est pas dans un widget scrollable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laisse le temps au défilement de se stabiliser avant de mesurer.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_spotRect = _measure(targetContext, _step.spotPadding);
|
||||||
|
_ready = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect? _measure(BuildContext targetContext, double padding) {
|
||||||
|
if (!targetContext.mounted) return null;
|
||||||
|
final renderObject = targetContext.findRenderObject();
|
||||||
|
if (renderObject is! RenderBox || !renderObject.hasSize) return null;
|
||||||
|
try {
|
||||||
|
final origin = renderObject.localToGlobal(Offset.zero);
|
||||||
|
final screen = MediaQuery.of(context).size;
|
||||||
|
return Rect.fromLTWH(origin.dx, origin.dy, renderObject.size.width, renderObject.size.height)
|
||||||
|
.inflate(padding)
|
||||||
|
.intersect(Offset.zero & screen);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _next() {
|
||||||
|
if (_isLast) {
|
||||||
|
_finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_index++;
|
||||||
|
_ready = widget.steps[_index].targetKey == null;
|
||||||
|
_spotRect = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _finish() => widget.onFinished();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final primary = theme.colorScheme.primary;
|
||||||
|
final spot = _spotRect;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop) _finish();
|
||||||
|
},
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _ready ? _next : null,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Voile sombre percé autour de l'élément mis en avant.
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _pulseController,
|
||||||
|
builder: (context, _) => CustomPaint(
|
||||||
|
painter: _SpotlightPainter(
|
||||||
|
spot: spot,
|
||||||
|
circle: _step.shape == TutorialHighlightShape.circle,
|
||||||
|
pulse: _pulseController.value,
|
||||||
|
glowColor: primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Main animée posée sur l'élément mis en avant.
|
||||||
|
if (spot != null && _step.gesture != TutorialGesture.none)
|
||||||
|
_buildHand(spot, primary),
|
||||||
|
|
||||||
|
// Bulle explicative.
|
||||||
|
if (_ready) _buildTooltip(context, spot, primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHand(Rect spot, Color primary) {
|
||||||
|
final handSize =
|
||||||
|
(spot.shortestSide * 1.4).clamp(96.0, 190.0).toDouble();
|
||||||
|
// Sur une grande zone (image plein écran), la main reste au centre ;
|
||||||
|
// sur un bouton, elle se cale sur le centre du bouton.
|
||||||
|
final center = spot.center;
|
||||||
|
return Positioned(
|
||||||
|
left: center.dx - handSize / 2,
|
||||||
|
top: center.dy - handSize / 2,
|
||||||
|
child: TutorialHand(
|
||||||
|
gesture: _step.gesture,
|
||||||
|
color: primary,
|
||||||
|
size: handSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTooltip(BuildContext context, Rect? spot, Color primary) {
|
||||||
|
final media = MediaQuery.of(context);
|
||||||
|
final screenHeight = media.size.height;
|
||||||
|
|
||||||
|
final card = _TutorialCard(
|
||||||
|
step: _step,
|
||||||
|
index: _index,
|
||||||
|
total: widget.steps.length,
|
||||||
|
primary: primary,
|
||||||
|
isLast: _isLast,
|
||||||
|
onNext: _next,
|
||||||
|
onSkip: _finish,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (spot == null) {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Center(
|
||||||
|
child: card,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = 22.0;
|
||||||
|
|
||||||
|
// Zone très large (image plein écran) : la bulle flotte en bas de l'écran
|
||||||
|
// pour laisser la main animée visible au centre.
|
||||||
|
if (spot.height > screenHeight * 0.55) {
|
||||||
|
final available = (screenHeight * 0.4).clamp(140.0, screenHeight);
|
||||||
|
return Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: media.padding.bottom + 24,
|
||||||
|
child: SafeArea(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: available),
|
||||||
|
child: Center(
|
||||||
|
child: card,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon, la bulle se place du côté le plus dégagé du spot.
|
||||||
|
final above =
|
||||||
|
_step.preferTooltipAbove ?? (spot.top > screenHeight - spot.bottom);
|
||||||
|
|
||||||
|
final available = above
|
||||||
|
? (spot.top - gap - media.padding.top).clamp(140.0, screenHeight)
|
||||||
|
: (screenHeight - spot.bottom - gap - media.padding.bottom).clamp(140.0, screenHeight);
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: above ? null : (spot.bottom + gap),
|
||||||
|
bottom: above ? (screenHeight - spot.top + gap) : null,
|
||||||
|
child: SafeArea(
|
||||||
|
top: !above,
|
||||||
|
bottom: above,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: available),
|
||||||
|
child: Center(
|
||||||
|
child: card,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carte explicative d'une étape (titre, texte, progression, boutons).
|
||||||
|
class _TutorialCard extends StatelessWidget {
|
||||||
|
final TutorialStep step;
|
||||||
|
final int index;
|
||||||
|
final int total;
|
||||||
|
final Color primary;
|
||||||
|
final bool isLast;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final VoidCallback onSkip;
|
||||||
|
|
||||||
|
const _TutorialCard({
|
||||||
|
required this.step,
|
||||||
|
required this.index,
|
||||||
|
required this.total,
|
||||||
|
required this.primary,
|
||||||
|
required this.isLast,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onSkip,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final surface = isDark ? const Color(0xFF121A26) : Colors.white;
|
||||||
|
final textPrimary =
|
||||||
|
isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary;
|
||||||
|
final textSecondary =
|
||||||
|
isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: primary.withValues(alpha: 0.35), width: 1.2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: isDark ? 0.6 : 0.25),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
BoxShadow(
|
||||||
|
color: primary.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 24,
|
||||||
|
spreadRadius: -6,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primary.withValues(alpha: isDark ? 0.22 : 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
step.icon ?? Icons.school_outlined,
|
||||||
|
color: primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
step.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: textPrimary,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${index + 1}/$total',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
step.description,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
height: 1.4,
|
||||||
|
color: textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (step.gesture != TutorialGesture.none) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.gesture, size: 16, color: primary),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_gestureHint(step.gesture),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
...List.generate(total, (i) {
|
||||||
|
final active = i == index;
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
margin: const EdgeInsets.only(right: 5),
|
||||||
|
width: active ? 18 : 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active
|
||||||
|
? primary
|
||||||
|
: primary.withValues(alpha: 0.28),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: onSkip,
|
||||||
|
style: TextButton.styleFrom(foregroundColor: textSecondary),
|
||||||
|
child: const Text('Passer'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onNext,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 18, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isLast ? 'C\'est parti' : 'Suivant',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _gestureHint(TutorialGesture gesture) {
|
||||||
|
switch (gesture) {
|
||||||
|
case TutorialGesture.tap:
|
||||||
|
return 'Appuyez une fois';
|
||||||
|
case TutorialGesture.doubleTap:
|
||||||
|
return 'Appuyez deux fois';
|
||||||
|
case TutorialGesture.longPress:
|
||||||
|
return 'Appui long';
|
||||||
|
case TutorialGesture.drag:
|
||||||
|
return 'Appui long puis glisser';
|
||||||
|
case TutorialGesture.pinch:
|
||||||
|
return 'Pincez avec deux doigts pour zoomer';
|
||||||
|
case TutorialGesture.swipeUp:
|
||||||
|
return 'Balayez vers le haut';
|
||||||
|
case TutorialGesture.swipeHorizontal:
|
||||||
|
return 'Balayez latéralement';
|
||||||
|
case TutorialGesture.none:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voile sombre percé d'un trou de lumière, avec anneau pulsant.
|
||||||
|
class _SpotlightPainter extends CustomPainter {
|
||||||
|
final Rect? spot;
|
||||||
|
final bool circle;
|
||||||
|
final double pulse;
|
||||||
|
final Color glowColor;
|
||||||
|
|
||||||
|
_SpotlightPainter({
|
||||||
|
required this.spot,
|
||||||
|
required this.circle,
|
||||||
|
required this.pulse,
|
||||||
|
required this.glowColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final scrim = Paint()..color = Colors.black.withValues(alpha: 0.76);
|
||||||
|
final screenPath = Path()..addRect(Offset.zero & size);
|
||||||
|
final target = spot;
|
||||||
|
|
||||||
|
if (target == null || target.isEmpty) {
|
||||||
|
canvas.drawPath(screenPath, scrim);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final holePath = circle
|
||||||
|
? (Path()
|
||||||
|
..addOval(Rect.fromCircle(
|
||||||
|
center: target.center,
|
||||||
|
radius: target.longestSide / 2,
|
||||||
|
)))
|
||||||
|
: (Path()
|
||||||
|
..addRRect(RRect.fromRectAndRadius(
|
||||||
|
target,
|
||||||
|
const Radius.circular(18),
|
||||||
|
)));
|
||||||
|
|
||||||
|
canvas.drawPath(
|
||||||
|
Path.combine(PathOperation.difference, screenPath, holePath),
|
||||||
|
scrim,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Halo diffus autour du trou.
|
||||||
|
canvas.drawPath(
|
||||||
|
holePath,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 10
|
||||||
|
..color = glowColor.withValues(alpha: 0.28)
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Contour net.
|
||||||
|
canvas.drawPath(
|
||||||
|
holePath,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.4
|
||||||
|
..color = glowColor.withValues(alpha: 0.95),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anneau qui s'écarte en boucle pour attirer l'œil.
|
||||||
|
final ringPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.5
|
||||||
|
..color = glowColor.withValues(alpha: 0.5 * (1 - pulse));
|
||||||
|
final expansion = target.shortestSide * 0.10 * pulse;
|
||||||
|
|
||||||
|
if (circle) {
|
||||||
|
canvas.drawCircle(
|
||||||
|
target.center,
|
||||||
|
target.longestSide / 2 + expansion,
|
||||||
|
ringPaint,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
target.inflate(expansion),
|
||||||
|
Radius.circular(18 + expansion),
|
||||||
|
),
|
||||||
|
ringPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_SpotlightPainter old) =>
|
||||||
|
old.spot != spot ||
|
||||||
|
old.pulse != pulse ||
|
||||||
|
old.circle != circle ||
|
||||||
|
old.glowColor != glowColor;
|
||||||
|
}
|
||||||